512 lines
15 KiB
Vue
512 lines
15 KiB
Vue
<template>
|
|
<v-container fluid>
|
|
<!-- Header -->
|
|
<v-row class="mb-6">
|
|
<v-col>
|
|
<h1 class="text-h3 font-weight-bold mb-2">Member Management</h1>
|
|
<p class="text-body-1 text-medium-emphasis">Manage association members and their information</p>
|
|
</v-col>
|
|
<v-col cols="auto">
|
|
<v-btn
|
|
color="primary"
|
|
variant="flat"
|
|
prepend-icon="mdi-account-plus"
|
|
@click="showCreateDialog = true"
|
|
>
|
|
Add Member
|
|
</v-btn>
|
|
</v-col>
|
|
</v-row>
|
|
|
|
<!-- Stats Cards -->
|
|
<v-row class="mb-6">
|
|
<v-col cols="12" md="3">
|
|
<v-card elevation="2">
|
|
<v-card-text>
|
|
<div class="d-flex align-center justify-space-between">
|
|
<div>
|
|
<div class="text-h4 font-weight-bold">{{ stats.total }}</div>
|
|
<div class="text-body-2 text-medium-emphasis">Total Members</div>
|
|
</div>
|
|
<v-icon size="32" color="primary">mdi-account-group</v-icon>
|
|
</div>
|
|
</v-card-text>
|
|
</v-card>
|
|
</v-col>
|
|
<v-col cols="12" md="3">
|
|
<v-card elevation="2">
|
|
<v-card-text>
|
|
<div class="d-flex align-center justify-space-between">
|
|
<div>
|
|
<div class="text-h4 font-weight-bold">{{ stats.active }}</div>
|
|
<div class="text-body-2 text-medium-emphasis">Active Members</div>
|
|
</div>
|
|
<v-icon size="32" color="success">mdi-account-check</v-icon>
|
|
</div>
|
|
</v-card-text>
|
|
</v-card>
|
|
</v-col>
|
|
<v-col cols="12" md="3">
|
|
<v-card elevation="2">
|
|
<v-card-text>
|
|
<div class="d-flex align-center justify-space-between">
|
|
<div>
|
|
<div class="text-h4 font-weight-bold">{{ stats.newThisMonth }}</div>
|
|
<div class="text-body-2 text-medium-emphasis">New This Month</div>
|
|
</div>
|
|
<v-icon size="32" color="info">mdi-account-plus-outline</v-icon>
|
|
</div>
|
|
</v-card-text>
|
|
</v-card>
|
|
</v-col>
|
|
<v-col cols="12" md="3">
|
|
<v-card elevation="2">
|
|
<v-card-text>
|
|
<div class="d-flex align-center justify-space-between">
|
|
<div>
|
|
<div class="text-h4 font-weight-bold">{{ stats.renewalDue }}</div>
|
|
<div class="text-body-2 text-medium-emphasis">Renewal Due</div>
|
|
</div>
|
|
<v-icon size="32" color="warning">mdi-clock-alert-outline</v-icon>
|
|
</div>
|
|
</v-card-text>
|
|
</v-card>
|
|
</v-col>
|
|
</v-row>
|
|
|
|
<!-- Filters -->
|
|
<v-card class="mb-6" elevation="0">
|
|
<v-card-text>
|
|
<v-row>
|
|
<v-col cols="12" md="3">
|
|
<v-text-field
|
|
v-model="searchQuery"
|
|
label="Search members"
|
|
prepend-inner-icon="mdi-magnify"
|
|
variant="outlined"
|
|
density="compact"
|
|
clearable
|
|
hide-details
|
|
/>
|
|
</v-col>
|
|
<v-col cols="12" md="3">
|
|
<v-select
|
|
v-model="statusFilter"
|
|
label="Status"
|
|
:items="statusOptions"
|
|
variant="outlined"
|
|
density="compact"
|
|
clearable
|
|
hide-details
|
|
/>
|
|
</v-col>
|
|
<v-col cols="12" md="3">
|
|
<v-select
|
|
v-model="membershipFilter"
|
|
label="Membership Type"
|
|
:items="membershipOptions"
|
|
variant="outlined"
|
|
density="compact"
|
|
clearable
|
|
hide-details
|
|
/>
|
|
</v-col>
|
|
<v-col cols="12" md="3">
|
|
<v-btn
|
|
variant="outlined"
|
|
color="primary"
|
|
block
|
|
@click="exportMembers"
|
|
>
|
|
<v-icon start>mdi-download</v-icon>
|
|
Export List
|
|
</v-btn>
|
|
</v-col>
|
|
</v-row>
|
|
</v-card-text>
|
|
</v-card>
|
|
|
|
<!-- Members Table -->
|
|
<v-card elevation="2">
|
|
<v-data-table
|
|
:headers="headers"
|
|
:items="filteredMembers"
|
|
:search="searchQuery"
|
|
:loading="loading"
|
|
class="elevation-0"
|
|
hover
|
|
:items-per-page="10"
|
|
>
|
|
<template v-slot:item.name="{ item }">
|
|
<div class="d-flex align-center py-2">
|
|
<ProfileAvatar
|
|
:member-id="item.member_id"
|
|
:first-name="item.first_name"
|
|
:last-name="item.last_name"
|
|
size="40"
|
|
class="mr-3"
|
|
/>
|
|
<div>
|
|
<div class="font-weight-medium">{{ item.first_name }} {{ item.last_name }}</div>
|
|
<div class="text-caption text-medium-emphasis">{{ item.email }}</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<template v-slot:item.membership="{ item }">
|
|
<v-chip
|
|
:color="getMembershipColor(item.membership_type)"
|
|
size="small"
|
|
variant="tonal"
|
|
>
|
|
{{ item.membership_type }}
|
|
</v-chip>
|
|
</template>
|
|
|
|
<template v-slot:item.status="{ item }">
|
|
<v-chip
|
|
:color="item.status === 'active' ? 'success' : 'error'"
|
|
size="small"
|
|
variant="tonal"
|
|
>
|
|
{{ item.status }}
|
|
</v-chip>
|
|
</template>
|
|
|
|
<template v-slot:item.dues_status="{ item }">
|
|
<v-chip
|
|
:color="getDuesColor(item.dues_status)"
|
|
size="small"
|
|
variant="tonal"
|
|
>
|
|
{{ item.dues_status }}
|
|
</v-chip>
|
|
</template>
|
|
|
|
<template v-slot:item.join_date="{ item }">
|
|
<span class="text-body-2">{{ formatDate(item.join_date) }}</span>
|
|
</template>
|
|
|
|
<template v-slot:item.actions="{ item }">
|
|
<v-btn
|
|
icon="mdi-eye"
|
|
size="small"
|
|
variant="text"
|
|
@click="viewMember(item)"
|
|
/>
|
|
<v-btn
|
|
icon="mdi-pencil"
|
|
size="small"
|
|
variant="text"
|
|
@click="editMember(item)"
|
|
/>
|
|
<v-btn
|
|
icon="mdi-dots-vertical"
|
|
size="small"
|
|
variant="text"
|
|
>
|
|
<v-menu activator="parent">
|
|
<v-list density="compact">
|
|
<v-list-item @click="sendEmail(item)">
|
|
<v-list-item-title>
|
|
<v-icon size="small" class="mr-2">mdi-email</v-icon>
|
|
Send Email
|
|
</v-list-item-title>
|
|
</v-list-item>
|
|
<v-list-item @click="viewPaymentHistory(item)">
|
|
<v-list-item-title>
|
|
<v-icon size="small" class="mr-2">mdi-history</v-icon>
|
|
Payment History
|
|
</v-list-item-title>
|
|
</v-list-item>
|
|
<v-list-item @click="generateInvoice(item)">
|
|
<v-list-item-title>
|
|
<v-icon size="small" class="mr-2">mdi-file-document</v-icon>
|
|
Generate Invoice
|
|
</v-list-item-title>
|
|
</v-list-item>
|
|
<v-divider />
|
|
<v-list-item
|
|
@click="toggleStatus(item)"
|
|
:class="item.status === 'active' ? 'text-error' : 'text-success'"
|
|
>
|
|
<v-list-item-title>
|
|
<v-icon size="small" class="mr-2">
|
|
{{ item.status === 'active' ? 'mdi-account-off' : 'mdi-account-check' }}
|
|
</v-icon>
|
|
{{ item.status === 'active' ? 'Deactivate' : 'Activate' }}
|
|
</v-list-item-title>
|
|
</v-list-item>
|
|
</v-list>
|
|
</v-menu>
|
|
</v-btn>
|
|
</template>
|
|
</v-data-table>
|
|
</v-card>
|
|
|
|
<!-- View Member Dialog -->
|
|
<ViewMemberDialog
|
|
v-model="showViewDialog"
|
|
:member="selectedMember"
|
|
@edit="handleEditMember"
|
|
/>
|
|
|
|
<!-- Edit Member Dialog -->
|
|
<EditMemberDialog
|
|
v-model="showEditDialog"
|
|
:member="selectedMember"
|
|
@member-updated="handleMemberUpdated"
|
|
/>
|
|
|
|
<!-- Create Member Dialog -->
|
|
<v-dialog v-model="showCreateDialog" max-width="600">
|
|
<v-card>
|
|
<v-card-title>Add New Member</v-card-title>
|
|
<v-card-text>
|
|
<v-form ref="memberForm">
|
|
<v-row>
|
|
<v-col cols="12" md="6">
|
|
<v-text-field
|
|
v-model="memberForm.first_name"
|
|
label="First Name"
|
|
variant="outlined"
|
|
required
|
|
/>
|
|
</v-col>
|
|
<v-col cols="12" md="6">
|
|
<v-text-field
|
|
v-model="memberForm.last_name"
|
|
label="Last Name"
|
|
variant="outlined"
|
|
required
|
|
/>
|
|
</v-col>
|
|
<v-col cols="12">
|
|
<v-text-field
|
|
v-model="memberForm.email"
|
|
label="Email"
|
|
variant="outlined"
|
|
type="email"
|
|
required
|
|
/>
|
|
</v-col>
|
|
<v-col cols="12" md="6">
|
|
<v-select
|
|
v-model="memberForm.membership_type"
|
|
label="Membership Type"
|
|
:items="membershipOptions"
|
|
variant="outlined"
|
|
required
|
|
/>
|
|
</v-col>
|
|
<v-col cols="12" md="6">
|
|
<v-text-field
|
|
v-model="memberForm.phone"
|
|
label="Phone"
|
|
variant="outlined"
|
|
/>
|
|
</v-col>
|
|
</v-row>
|
|
</v-form>
|
|
</v-card-text>
|
|
<v-card-actions>
|
|
<v-spacer />
|
|
<v-btn variant="text" @click="showCreateDialog = false">Cancel</v-btn>
|
|
<v-btn color="primary" variant="flat" @click="saveMember">Create</v-btn>
|
|
</v-card-actions>
|
|
</v-card>
|
|
</v-dialog>
|
|
</v-container>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import type { Member } from '~/utils/types';
|
|
|
|
definePageMeta({
|
|
layout: 'admin',
|
|
middleware: 'admin'
|
|
});
|
|
|
|
// State
|
|
const loading = ref(false);
|
|
const showViewDialog = ref(false);
|
|
const showEditDialog = ref(false);
|
|
const showCreateDialog = ref(false);
|
|
const selectedMember = ref<Member | null>(null);
|
|
const searchQuery = ref('');
|
|
const statusFilter = ref(null);
|
|
const membershipFilter = ref(null);
|
|
|
|
// Stats
|
|
const stats = ref({
|
|
total: 0,
|
|
active: 0,
|
|
newThisMonth: 0,
|
|
renewalDue: 0
|
|
});
|
|
|
|
// Form data
|
|
const memberForm = ref({
|
|
first_name: '',
|
|
last_name: '',
|
|
email: '',
|
|
membership_type: 'Standard',
|
|
phone: ''
|
|
});
|
|
|
|
// Options
|
|
const statusOptions = ['active', 'inactive'];
|
|
const membershipOptions = ['Standard', 'Premium', 'VIP', 'Lifetime'];
|
|
|
|
// Table configuration
|
|
const headers = [
|
|
{ title: 'Member', key: 'name', sortable: true },
|
|
{ title: 'Membership', key: 'membership', sortable: true },
|
|
{ title: 'Status', key: 'status', sortable: true },
|
|
{ title: 'Dues', key: 'dues_status', sortable: true },
|
|
{ title: 'Joined', key: 'join_date', sortable: true },
|
|
{ title: 'Actions', key: 'actions', sortable: false, align: 'end' }
|
|
];
|
|
|
|
// Real data from API
|
|
const members = ref<Member[]>([]);
|
|
|
|
// Computed
|
|
const filteredMembers = computed(() => {
|
|
let filtered = [...members.value];
|
|
|
|
if (statusFilter.value) {
|
|
filtered = filtered.filter(m => m.status === statusFilter.value);
|
|
}
|
|
|
|
if (membershipFilter.value) {
|
|
filtered = filtered.filter(m => m.membership_type === membershipFilter.value);
|
|
}
|
|
|
|
return filtered;
|
|
});
|
|
|
|
// Methods
|
|
const getMembershipColor = (type: string) => {
|
|
switch (type) {
|
|
case 'VIP': return 'error';
|
|
case 'Premium': return 'warning';
|
|
case 'Lifetime': return 'purple';
|
|
default: return 'info';
|
|
}
|
|
};
|
|
|
|
const getDuesColor = (status: string) => {
|
|
switch (status) {
|
|
case 'Paid': return 'success';
|
|
case 'Due': return 'warning';
|
|
case 'Overdue': return 'error';
|
|
default: return 'default';
|
|
}
|
|
};
|
|
|
|
const formatDate = (date: string) => {
|
|
return new Date(date).toLocaleDateString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric'
|
|
});
|
|
};
|
|
|
|
const viewMember = (member: Member) => {
|
|
selectedMember.value = member;
|
|
showViewDialog.value = true;
|
|
};
|
|
|
|
const editMember = (member: Member) => {
|
|
selectedMember.value = member;
|
|
showEditDialog.value = true;
|
|
};
|
|
|
|
const handleEditMember = (member: Member) => {
|
|
showViewDialog.value = false;
|
|
selectedMember.value = member;
|
|
showEditDialog.value = true;
|
|
};
|
|
|
|
const handleMemberUpdated = (member: Member) => {
|
|
const index = members.value.findIndex(m => m.member_id === member.member_id);
|
|
if (index > -1) {
|
|
members.value[index] = member;
|
|
}
|
|
showEditDialog.value = false;
|
|
};
|
|
|
|
const sendEmail = (member: Member) => {
|
|
console.log('Send email to:', member);
|
|
};
|
|
|
|
const viewPaymentHistory = (member: Member) => {
|
|
console.log('View payment history:', member);
|
|
};
|
|
|
|
const generateInvoice = (member: Member) => {
|
|
console.log('Generate invoice:', member);
|
|
};
|
|
|
|
const toggleStatus = (member: Member) => {
|
|
member.status = member.status === 'active' ? 'inactive' : 'active';
|
|
};
|
|
|
|
const exportMembers = () => {
|
|
console.log('Export members list');
|
|
};
|
|
|
|
const saveMember = () => {
|
|
console.log('Save member:', memberForm.value);
|
|
showCreateDialog.value = false;
|
|
};
|
|
|
|
// Load real members data from API
|
|
const loadMembers = async () => {
|
|
loading.value = true;
|
|
try {
|
|
// Fetch members from API
|
|
const { data } = await $fetch('/api/members');
|
|
|
|
if (data?.members) {
|
|
// Transform the data to match our interface
|
|
members.value = data.members.map((member: any) => ({
|
|
member_id: member.Id || member.id,
|
|
first_name: member.first_name,
|
|
last_name: member.last_name,
|
|
email: member.email,
|
|
membership_type: member.membership_type || 'Standard',
|
|
status: member.membership_status === 'Active' ? 'active' : 'inactive',
|
|
dues_status: member.dues_status || 'Unknown',
|
|
join_date: member.member_since || member.created_at,
|
|
phone: member.phone_number || member.phone || ''
|
|
}));
|
|
|
|
// Calculate stats from real data
|
|
const now = new Date();
|
|
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
|
|
|
stats.value = {
|
|
total: members.value.length,
|
|
active: members.value.filter(m => m.status === 'active').length,
|
|
newThisMonth: members.value.filter(m => {
|
|
const joinDate = new Date(m.join_date);
|
|
return joinDate >= startOfMonth;
|
|
}).length,
|
|
renewalDue: members.value.filter(m => m.dues_status === 'Due' || m.dues_status === 'Overdue').length
|
|
};
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading members:', error);
|
|
// Keep empty array if load fails
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
// Load data on mount
|
|
onMounted(async () => {
|
|
await loadMembers();
|
|
});
|
|
</script> |