Files
bowlingManager/frontend/src/views/club/MemberManagement.vue
T

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>