init
This commit is contained in:
@@ -0,0 +1,558 @@
|
||||
<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]"
|
||||
stripedRows responsiveLayout="scroll">
|
||||
<Column field="name" header="이름" sortable style="width: 15%"></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="field">
|
||||
<label for="name">이름</label>
|
||||
<InputText id="name" v-model="member.name" required autofocus />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="gender">성별</label>
|
||||
<Dropdown id="gender" v-model="member.gender" :options="genderOptions"
|
||||
optionLabel="name" placeholder="성별 선택" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="memberType">회원 유형</label>
|
||||
<Dropdown id="memberType" v-model="member.memberTypeObj" :options="memberTypes"
|
||||
optionLabel="name" placeholder="회원 유형 선택" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="phone">전화번호</label>
|
||||
<InputText id="phone" v-model="member.phone" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="email">이메일</label>
|
||||
<InputText id="email" v-model="member.email" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="handicap">핸디캡</label>
|
||||
<InputText id="handicap" v-model="member.handicap" type="number" min="0" max="50" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="status">상태</label>
|
||||
<Dropdown id="status" v-model="member.status" :options="statusOptions"
|
||||
optionLabel="label" optionValue="value" placeholder="상태 선택" />
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="취소" icon="pi pi-times" class="p-button-text" @click="hideDialog" />
|
||||
<Button label="저장" icon="pi pi-check" class="p-button-text" @click="saveMember" />
|
||||
</template>
|
||||
</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 } from 'vue';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import Button from 'primevue/button';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import MultiSelect from 'primevue/multiselect';
|
||||
import InputNumber from 'primevue/inputnumber';
|
||||
import Dialog from 'primevue/dialog';
|
||||
import DataTable from 'primevue/datatable';
|
||||
import Column from 'primevue/column';
|
||||
import apiClient from '@/services/api';
|
||||
import clubService from '@/services/clubService';
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
// 회원 유형 목록
|
||||
const memberTypes = ref([
|
||||
{ name: '정회원', code: 'regular' },
|
||||
{ name: '준회원', code: 'associate' },
|
||||
{ name: '운영진', code: 'admin' },
|
||||
{ name: '모임장', code: 'owner' }
|
||||
]);
|
||||
|
||||
// 성별 옵션
|
||||
const genderOptions = ref([
|
||||
{ name: '남성', code: 'male' },
|
||||
{ name: '여성', code: 'female' }
|
||||
]);
|
||||
|
||||
// 회원 목록
|
||||
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;
|
||||
};
|
||||
|
||||
// 회원 수정 다이얼로그 열기
|
||||
const editMember = (data) => {
|
||||
member.value = { ...data };
|
||||
dialogMode.value = 'edit';
|
||||
memberDialog.value = true;
|
||||
};
|
||||
|
||||
// 다이얼로그 닫기
|
||||
const hideDialog = () => {
|
||||
memberDialog.value = false;
|
||||
};
|
||||
|
||||
// 회원 저장
|
||||
const saveMember = async () => {
|
||||
try {
|
||||
if (dialogMode.value === 'new') {
|
||||
await clubService.addClubMember(clubId.value, member.value);
|
||||
toast.add({ severity: 'success', summary: '성공', detail: '새 회원이 추가되었습니다.', life: 3000 });
|
||||
} else {
|
||||
await clubService.updateClubMember(clubId.value, member.value.id, member.value);
|
||||
toast.add({ severity: 'success', summary: '성공', detail: '회원 정보가 수정되었습니다.', life: 3000 });
|
||||
}
|
||||
|
||||
hideDialog();
|
||||
await loadMembers();
|
||||
} catch (error) {
|
||||
console.error('회원 저장 중 오류:', error);
|
||||
toast.add({ severity: 'error', summary: '오류', detail: '회원 정보 저장 중 오류가 발생했습니다.', 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;
|
||||
}
|
||||
|
||||
/* 필터 다이얼로그 스타일 */
|
||||
: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>
|
||||
Reference in New Issue
Block a user