611 lines
19 KiB
Vue
611 lines
19 KiB
Vue
<template>
|
|
<div class="member-management">
|
|
<div class="header">
|
|
<h2>회원 관리</h2>
|
|
<Button label="새 회원 추가" icon="pi pi-plus" @click="openNewMemberDialog" />
|
|
</div>
|
|
|
|
<div class="filter-section">
|
|
<div class="grid">
|
|
<div class="col-12 md:col-6">
|
|
<div class="w-full">
|
|
<i class="pi pi-search" />
|
|
<InputText v-model="filters.searchText" placeholder="이름, 전화번호, 이메일로 검색" class="w-full searchText" />
|
|
</div>
|
|
</div>
|
|
<div class="col-12 md:col-6 text-right">
|
|
<Button icon="pi pi-filter" label="상세 필터" @click="filterDialog = true"
|
|
:class="{'p-button-info': isFilterActive}" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<DataTable :value="filteredMembers" :paginator="true" :rows="10"
|
|
:rowsPerPageOptions="[5, 10, 20]"
|
|
sortField="name"
|
|
:sortOrder="1"
|
|
stripedRows responsiveLayout="scroll">
|
|
<Column field="name" header="이름" sortable style="width: 15%">
|
|
<template #body="slotProps">
|
|
<div class="member-name">
|
|
<div class="name">{{ slotProps.data.name }}</div>
|
|
<div class="contact-info">
|
|
<div v-if="slotProps.data.phone" class="phone">{{ slotProps.data.phone }}</div>
|
|
<div v-if="slotProps.data.email" class="email">{{ slotProps.data.email }}</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</Column>
|
|
<Column field="gender" header="성별" sortable style="width: 10%"></Column>
|
|
<Column field="memberType" header="회원 유형" sortable style="width: 15%">
|
|
<template #body="slotProps">
|
|
<span :class="getMemberTypeClass(slotProps.data.memberType)">
|
|
{{ slotProps.data.memberType }}
|
|
</span>
|
|
</template>
|
|
</Column>
|
|
<Column field="handicap" header="핸디캡" sortable style="width: 10%"></Column>
|
|
<Column field="average" header="에버리지" sortable style="width: 10%"></Column>
|
|
<Column field="games" header="게임 수" sortable style="width: 10%"></Column>
|
|
<Column field="joinDate" header="가입일" sortable style="width: 10%">
|
|
<template #body="slotProps">
|
|
{{ formatDate(slotProps.data.joinDate) }}
|
|
</template>
|
|
</Column>
|
|
<Column field="status" header="상태" sortable style="width: 10%">
|
|
<template #body="slotProps">
|
|
<span :class="`status-${slotProps.data.status}`">
|
|
{{ getStatusLabel(slotProps.data.status) }}
|
|
</span>
|
|
</template>
|
|
</Column>
|
|
<Column header="관리" style="width: 15%">
|
|
<template #body="slotProps">
|
|
<Button icon="pi pi-pencil" class="p-button-rounded p-button-success mr-2"
|
|
@click="editMember(slotProps.data)" />
|
|
<Button icon="pi pi-trash" class="p-button-rounded p-button-danger"
|
|
@click="confirmDeleteMember(slotProps.data)" />
|
|
</template>
|
|
</Column>
|
|
</DataTable>
|
|
|
|
<!-- 새 회원 추가/수정 다이얼로그 -->
|
|
<Dialog v-model:visible="memberDialog" :style="{width: '450px'}"
|
|
:header="dialogMode === 'new' ? '새 회원 추가' : '회원 정보 수정'"
|
|
:modal="true" class="p-fluid">
|
|
<!-- 회원 정보 다이얼로그 내부 폼 예시 -->
|
|
<div class="grid">
|
|
<div class="field col-12 mb-3">
|
|
<label for="name" class="mb-1">이름</label>
|
|
<InputText id="name" v-model="member.name" class="w-full" />
|
|
</div>
|
|
<div class="field col-6 mb-3">
|
|
<label for="gender" class="mb-1">성별</label>
|
|
<Select id="gender" v-model="member.gender" :options="genderOptions" optionLabel="name" optionValue="code" placeholder="성별 선택" class="w-full" />
|
|
</div>
|
|
<div class="field col-6 mb-3">
|
|
<label for="memberType" class="mb-1">회원 유형</label>
|
|
<Select id="memberType" v-model="member.memberType" :options="memberTypeOptions" optionLabel="name" optionValue="code" placeholder="회원 유형 선택" class="w-full" />
|
|
</div>
|
|
<div class="field col-6 mb-3">
|
|
<label for="phone" class="mb-1">전화번호</label>
|
|
<InputText id="phone" v-model="member.phone" class="w-full" />
|
|
</div>
|
|
<div class="field col-6 mb-3">
|
|
<label for="email" class="mb-1">이메일</label>
|
|
<InputText id="email" v-model="member.email" class="w-full" />
|
|
</div>
|
|
<div class="field col-6 mb-3">
|
|
<label for="handicap" class="mb-1">핸디캡</label>
|
|
<InputText id="handicap" v-model="member.handicap" type="number" min="0" max="50" class="w-full" />
|
|
</div>
|
|
<div class="field col-6 mb-3">
|
|
<label for="status" class="mb-1">상태</label>
|
|
<Select id="status" v-model="member.status" :options="statusOptions" optionLabel="label" optionValue="value" placeholder="상태 선택" class="w-full" />
|
|
</div>
|
|
</div>
|
|
<div class="flex justify-content-end gap-2 mt-4">
|
|
<Button label="취소" icon="pi pi-times" class="p-button-text" @click="hideDialog" />
|
|
<Button label="저장" icon="pi pi-check" class="p-button-text" @click="saveMember" />
|
|
</div>
|
|
</Dialog>
|
|
|
|
<!-- 삭제 확인 다이얼로그 -->
|
|
<Dialog v-model:visible="deleteDialog" :style="{width: '450px'}"
|
|
header="회원 삭제" :modal="true">
|
|
<div class="confirmation-content">
|
|
<i class="pi pi-exclamation-triangle mr-3" style="font-size: 2rem" />
|
|
<span v-if="selectedMember">정말로 <b>{{ selectedMember.name }}</b> 회원을 삭제하시겠습니까?</span>
|
|
</div>
|
|
<template #footer>
|
|
<Button label="아니오" icon="pi pi-times" class="p-button-text" @click="deleteDialog = false" />
|
|
<Button label="예" icon="pi pi-check" class="p-button-text" @click="deleteMember" />
|
|
</template>
|
|
</Dialog>
|
|
|
|
<!-- 상세 필터 다이얼로그 -->
|
|
<Dialog v-model:visible="filterDialog" header="상세 필터" :style="{width: '450px'}" :modal="true">
|
|
<div class="grid p-fluid">
|
|
<div class="col-12 md:col-4">
|
|
<div class="field">
|
|
<label>회원 유형</label>
|
|
<MultiSelect v-model="filters.memberTypes" :options="memberTypes" optionLabel="name"
|
|
placeholder="선택" class="w-full" />
|
|
</div>
|
|
</div>
|
|
<div class="col-12 md:col-4">
|
|
<div class="field">
|
|
<label>성별</label>
|
|
<MultiSelect v-model="filters.genders" :options="genderOptions" optionLabel="name"
|
|
placeholder="선택" class="w-full" />
|
|
</div>
|
|
</div>
|
|
<div class="col-12 md:col-4">
|
|
<div class="field">
|
|
<label>상태</label>
|
|
<MultiSelect v-model="filters.statuses" :options="statusOptions" optionLabel="label"
|
|
placeholder="선택" class="w-full" />
|
|
</div>
|
|
</div>
|
|
<div class="col-12 md:col-4">
|
|
<div class="field">
|
|
<label>핸디캡</label>
|
|
<div class="p-inputgroup">
|
|
<InputNumber v-model="filters.handicapFrom" placeholder="최소" class="p-inputgroup-left" size="small" />
|
|
<InputNumber v-model="filters.handicapTo" placeholder="최대" class="p-inputgroup-right" size="small" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-12 md:col-4">
|
|
<div class="field">
|
|
<label>에버리지</label>
|
|
<div class="p-inputgroup">
|
|
<InputNumber v-model="filters.averageFrom" placeholder="최소" mode="decimal"
|
|
:minFractionDigits="1" :maxFractionDigits="1" class="p-inputgroup-left" size="small" />
|
|
<InputNumber v-model="filters.averageTo" placeholder="최대" mode="decimal"
|
|
:minFractionDigits="1" :maxFractionDigits="1" class="p-inputgroup-right" size="small" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-12 md:col-4">
|
|
<div class="field">
|
|
<label>게임 수</label>
|
|
<div class="p-inputgroup">
|
|
<InputNumber v-model="filters.gamesFrom" placeholder="최소" class="p-inputgroup-left" size="small" />
|
|
<InputNumber v-model="filters.gamesTo" placeholder="최대" class="p-inputgroup-right" size="small" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<template #footer>
|
|
<Button label="필터 초기화" icon="pi pi-refresh" class="p-button-text" @click="resetFilters" />
|
|
<Button label="적용" icon="pi pi-check" @click="applyFilters" />
|
|
</template>
|
|
</Dialog>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, reactive, computed, onMounted, watch } from 'vue';
|
|
import clubService from '@/services/clubService';
|
|
import { useClubStore } from '@/stores/clubStore';
|
|
import { useToast } from 'primevue/usetoast';
|
|
|
|
const clubStore = useClubStore();
|
|
const toast = useToast();
|
|
|
|
// 회원 유형 옵션
|
|
const memberTypeOptions = ref([
|
|
{ name: '정회원', code: '정회원' },
|
|
{ name: '준회원', code: '준회원' },
|
|
{ name: '운영진', code: '운영진' }
|
|
// 모임장 옵션은 제거 - 클럽 설정에서만 변경 가능하도록 제한
|
|
]);
|
|
|
|
// 성별 옵션
|
|
const genderOptions = ref([
|
|
{ name: '남성', code: '남성' },
|
|
{ name: '여성', code: '여성' }
|
|
]);
|
|
|
|
// 회원 목록
|
|
const members = ref([]);
|
|
|
|
// 현재 클럽 ID
|
|
const clubId = ref(localStorage.getItem('clubId') || null);
|
|
|
|
// 데이터 로딩 상태
|
|
const loading = ref(true);
|
|
|
|
// 다이얼로그 상태
|
|
const memberDialog = ref(false);
|
|
const deleteDialog = ref(false);
|
|
const filterDialog = ref(false);
|
|
const dialogMode = ref('new');
|
|
const member = ref({});
|
|
const selectedMember = ref(null);
|
|
|
|
// 필터
|
|
const filters = reactive({
|
|
searchText: '',
|
|
memberTypes: [],
|
|
genders: [],
|
|
statuses: [],
|
|
handicapFrom: null,
|
|
handicapTo: null,
|
|
averageFrom: null,
|
|
averageTo: null,
|
|
gamesFrom: null,
|
|
gamesTo: null
|
|
});
|
|
|
|
// 필터 활성화 여부 확인
|
|
const isFilterActive = computed(() => {
|
|
return filters.memberTypes.length > 0 ||
|
|
filters.genders.length > 0 ||
|
|
filters.statuses.length > 0 ||
|
|
filters.handicapFrom ||
|
|
filters.handicapTo ||
|
|
filters.averageFrom ||
|
|
filters.averageTo ||
|
|
filters.gamesFrom ||
|
|
filters.gamesTo;
|
|
});
|
|
|
|
// 필터링된 회원 목록
|
|
const filteredMembers = computed(() => {
|
|
if (!members.value) return [];
|
|
|
|
return members.value.filter(member => {
|
|
// 기본 검색 (이름, 전화번호, 이메일)
|
|
if (filters.searchText) {
|
|
const searchText = filters.searchText.toLowerCase();
|
|
const matchesSearch =
|
|
member.name?.toLowerCase().includes(searchText) ||
|
|
member.phone?.toLowerCase().includes(searchText) ||
|
|
member.email?.toLowerCase().includes(searchText);
|
|
|
|
if (!matchesSearch) return false;
|
|
}
|
|
|
|
// 회원 유형 필터
|
|
if (filters.memberTypes.length > 0 &&
|
|
!filters.memberTypes.some(type => type.name === member.memberType)) {
|
|
return false;
|
|
}
|
|
|
|
// 성별 필터
|
|
if (filters.genders.length > 0 &&
|
|
!filters.genders.some(gender => gender.name === member.gender)) {
|
|
return false;
|
|
}
|
|
|
|
// 상태 필터
|
|
if (filters.statuses.length > 0 &&
|
|
!filters.statuses.some(status => status.value === member.status)) {
|
|
return false;
|
|
}
|
|
|
|
// 핸디캡 범위 필터
|
|
if (filters.handicapFrom !== null && member.handicap < filters.handicapFrom) return false;
|
|
if (filters.handicapTo !== null && member.handicap > filters.handicapTo) return false;
|
|
|
|
// 에버리지 범위 필터
|
|
if (filters.averageFrom !== null && member.average < filters.averageFrom) return false;
|
|
if (filters.averageTo !== null && member.average > filters.averageTo) return false;
|
|
|
|
// 게임 수 범위 필터
|
|
if (filters.gamesFrom !== null && member.games < filters.gamesFrom) return false;
|
|
if (filters.gamesTo !== null && member.games > filters.gamesTo) return false;
|
|
|
|
return true;
|
|
});
|
|
});
|
|
|
|
// 필터 초기화
|
|
const resetFilters = () => {
|
|
filters.memberTypes = [];
|
|
filters.genders = [];
|
|
filters.statuses = [];
|
|
filters.handicapFrom = null;
|
|
filters.handicapTo = null;
|
|
filters.averageFrom = null;
|
|
filters.averageTo = null;
|
|
filters.gamesFrom = null;
|
|
filters.gamesTo = null;
|
|
};
|
|
|
|
// 필터 적용
|
|
const applyFilters = () => {
|
|
filterDialog.value = false;
|
|
};
|
|
|
|
// 날짜 형식 변환 함수
|
|
const formatDate = (dateStr) => {
|
|
if (!dateStr) return '';
|
|
const date = new Date(dateStr);
|
|
return `${date.getFullYear()}.${String(date.getMonth() + 1).padStart(2, '0')}.${String(date.getDate()).padStart(2, '0')}`;
|
|
};
|
|
|
|
// 회원 상태 표시 함수
|
|
const getStatusLabel = (status) => {
|
|
switch (status) {
|
|
case 'active': return '활성';
|
|
case 'inactive': return '비활성';
|
|
case 'suspended': return '정지';
|
|
case 'deleted': return '삭제됨';
|
|
default: return status;
|
|
}
|
|
};
|
|
|
|
// 컴포넌트 마운트 시 데이터 로드
|
|
onMounted(async () => {
|
|
try {
|
|
loading.value = true;
|
|
await loadMembers();
|
|
} catch (error) {
|
|
console.error('회원 데이터 로딩 오류:', error);
|
|
toast.add({ severity: 'error', summary: '오류', detail: '회원 데이터 로딩 중 오류가 발생했습니다.', life: 3000 });
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
});
|
|
|
|
// 회원 목록 로드 함수
|
|
const loadMembers = async () => {
|
|
if (!clubId.value) {
|
|
console.error('클럽 ID가 없습니다.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await clubService.getClubMembers(clubId.value);
|
|
members.value = response.data || [];
|
|
} catch (error) {
|
|
console.error('회원 목록 로딩 중 오류 발생:', error);
|
|
throw error;
|
|
}
|
|
};
|
|
|
|
// 회원 유형에 따른 클래스 반환
|
|
const getMemberTypeClass = (type) => {
|
|
switch (type) {
|
|
case '모임장': return 'member-type-owner';
|
|
case '운영진': return 'member-type-admin';
|
|
case '정회원': return 'member-type-regular';
|
|
case '준회원': return 'member-type-associate';
|
|
default: return '';
|
|
}
|
|
};
|
|
|
|
// 새 회원 추가 다이얼로그 열기
|
|
const openNewMemberDialog = () => {
|
|
member.value = {
|
|
name: '',
|
|
gender: null,
|
|
memberTypeObj: null,
|
|
phone: '',
|
|
email: '',
|
|
handicap: 0
|
|
};
|
|
dialogMode.value = 'new';
|
|
memberDialog.value = true;
|
|
};
|
|
|
|
// 성별 선택 시 핸디캡 자동 설정
|
|
watch(() => member.value.gender, (newGender) => {
|
|
if (dialogMode.value === 'new' && newGender && clubStore.selectedClub) {
|
|
if (newGender.name === '여성' && clubStore.selectedClub.femaleHandicap) {
|
|
member.value.handicap = clubStore.selectedClub.femaleHandicap;
|
|
} else if (newGender.name === '남성') {
|
|
member.value.handicap = 0;
|
|
}
|
|
}
|
|
});
|
|
|
|
// 회원 수정 다이얼로그 열기
|
|
const editMember = (data) => {
|
|
member.value = {
|
|
...data
|
|
};
|
|
|
|
dialogMode.value = 'edit';
|
|
memberDialog.value = true;
|
|
};
|
|
|
|
// 다이얼로그 닫기
|
|
const hideDialog = () => {
|
|
memberDialog.value = false;
|
|
};
|
|
|
|
// 회원 저장
|
|
const saveMember = async () => {
|
|
try {
|
|
const memberData = { ...member.value };
|
|
|
|
const isNew = dialogMode.value === 'new';
|
|
const response = isNew
|
|
? await clubService.addClubMember(clubId.value, memberData)
|
|
: await clubService.updateClubMember(clubId.value, memberData.id, memberData);
|
|
|
|
toast.add({
|
|
severity: 'success',
|
|
summary: '성공',
|
|
detail: isNew ? '새 회원이 추가되었습니다.' : '회원 정보가 수정되었습니다.',
|
|
life: 3000
|
|
});
|
|
|
|
hideDialog();
|
|
await loadMembers();
|
|
} catch (error) {
|
|
console.error('회원 저장 중 오류:', error);
|
|
// 서버에서 오류 메시지가 있는 경우 해당 메시지 표시
|
|
const errorMessage = error.response?.data?.message || '회원 정보 저장 중 오류가 발생했습니다.';
|
|
toast.add({ severity: 'error', summary: '오류', detail: errorMessage, life: 3000 });
|
|
}
|
|
};
|
|
|
|
// 회원 삭제 확인
|
|
const confirmDeleteMember = (data) => {
|
|
selectedMember.value = data;
|
|
deleteDialog.value = true;
|
|
};
|
|
|
|
// 회원 삭제
|
|
const deleteMember = async () => {
|
|
try {
|
|
await clubService.deleteClubMember(clubId.value, selectedMember.value.id);
|
|
deleteDialog.value = false;
|
|
toast.add({ severity: 'success', summary: '성공', detail: '회원이 삭제되었습니다.', life: 3000 });
|
|
await loadMembers();
|
|
} catch (error) {
|
|
console.error('회원 삭제 중 오류:', error);
|
|
toast.add({ severity: 'error', summary: '오류', detail: '회원 삭제 중 오류가 발생했습니다.', life: 3000 });
|
|
}
|
|
};
|
|
|
|
const statusOptions = ref([
|
|
{ label: '활성', value: 'active' },
|
|
{ label: '비활성', value: 'inactive' },
|
|
{ label: '정지', value: 'suspended' }
|
|
]);
|
|
</script>
|
|
|
|
<style scoped>
|
|
.member-management {
|
|
padding: 1rem;
|
|
}
|
|
|
|
.header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.header h2 {
|
|
margin: 0;
|
|
}
|
|
|
|
.filter-section {
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.member-type-regular {
|
|
color: #3B82F6;
|
|
font-weight: bold;
|
|
}
|
|
|
|
.member-type-associate {
|
|
color: #10B981;
|
|
font-weight: bold;
|
|
}
|
|
|
|
.member-type-admin {
|
|
color: #F59E0B;
|
|
font-weight: bold;
|
|
}
|
|
|
|
.member-type-owner {
|
|
color: #EF4444;
|
|
font-weight: bold;
|
|
}
|
|
|
|
/* 회원 이름, 연락처 스타일 */
|
|
.member-name {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.member-name .name {
|
|
font-weight: bold;
|
|
margin-bottom: 2px;
|
|
}
|
|
|
|
.member-name .contact-info {
|
|
display: flex;
|
|
flex-direction: column;
|
|
font-size: 0.75rem;
|
|
color: var(--text-color-secondary);
|
|
}
|
|
|
|
.member-name .phone,
|
|
.member-name .email {
|
|
white-space: nowrap;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
}
|
|
|
|
/* 필터 다이얼로그 스타일 */
|
|
:deep(.p-dialog-content) {
|
|
padding: 1rem;
|
|
}
|
|
|
|
:deep(.field) {
|
|
margin-bottom: 0.75rem;
|
|
}
|
|
|
|
:deep(.field label) {
|
|
display: block;
|
|
margin-bottom: 0.25rem;
|
|
font-size: 0.875rem;
|
|
color: var(--text-color-secondary);
|
|
}
|
|
|
|
:deep(.p-inputnumber-input) {
|
|
padding: 0.25rem 0.5rem;
|
|
}
|
|
|
|
/* InputGroup 스타일 */
|
|
:deep(.p-inputgroup) {
|
|
display: flex;
|
|
width: 100%;
|
|
position: relative;
|
|
}
|
|
|
|
:deep(.p-inputgroup .p-inputnumber) {
|
|
flex: 1;
|
|
}
|
|
|
|
:deep(.p-inputgroup .p-inputnumber-input) {
|
|
border-radius: 0;
|
|
width: 100%;
|
|
padding-left: 0.5rem !important;
|
|
}
|
|
|
|
:deep(.p-inputgroup-left .p-inputnumber-input) {
|
|
border-top-left-radius: 6px;
|
|
border-bottom-left-radius: 6px;
|
|
border-right: none;
|
|
}
|
|
|
|
:deep(.p-inputgroup-right .p-inputnumber-input) {
|
|
border-top-right-radius: 6px;
|
|
border-bottom-right-radius: 6px;
|
|
border-left: 1px solid var(--surface-border);
|
|
}
|
|
|
|
:deep(.p-inputgroup .p-inputnumber:focus-within) {
|
|
z-index: 1;
|
|
}
|
|
|
|
/* 검색 필드 스타일 */
|
|
:deep(.filter-section) {
|
|
position: relative;
|
|
}
|
|
|
|
:deep(.filter-section .searchText) {
|
|
width: 100%;
|
|
padding-left: 2.5rem !important;
|
|
}
|
|
|
|
:deep(.filter-section .pi-search) {
|
|
position: absolute;
|
|
left: 0.75rem;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
color: var(--text-color-secondary);
|
|
z-index: 2;
|
|
}
|
|
</style>
|