monacousa-portal/pages/admin/members/index.vue

799 lines
24 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" class="d-flex align-center gap-2">
<!-- View Toggle -->
<v-btn-toggle
v-model="viewMode"
mandatory
density="comfortable"
color="primary"
>
<v-btn icon value="list">
<v-icon>mdi-view-list</v-icon>
<v-tooltip activator="parent" location="bottom">List View</v-tooltip>
</v-btn>
<v-btn icon value="grid">
<v-icon>mdi-view-grid</v-icon>
<v-tooltip activator="parent" location="bottom">Grid View</v-tooltip>
</v-btn>
</v-btn-toggle>
<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 text-success">{{ stats.paidThisYear }}</div>
<div class="text-body-2 text-medium-emphasis">Dues Paid This Year</div>
</div>
<v-icon size="32" color="success">mdi-cash-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 text-warning">{{ stats.duesOutstanding }}</div>
<div class="text-body-2 text-medium-emphasis">Dues Outstanding</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="2">
<v-select
v-model="statusFilter"
label="Status"
:items="statusOptions"
variant="outlined"
density="compact"
clearable
hide-details
/>
</v-col>
<v-col cols="12" md="2">
<v-select
v-model="membershipFilter"
label="Membership Type"
:items="membershipOptions"
variant="outlined"
density="compact"
clearable
hide-details
/>
</v-col>
<v-col cols="12" md="2">
<v-select
v-model="duesFilter"
label="Dues Status"
:items="['Paid', 'Unpaid', 'Overdue']"
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>
<!-- List View -->
<v-card v-if="viewMode === 'list'" elevation="2">
<v-data-table
:headers="enhancedHeaders"
:items="filteredMembers"
:search="searchQuery"
:loading="loading"
class="elevation-0 member-list-table"
hover
:items-per-page="10"
@click:row="(e, { item }) => viewMember(item)"
>
<template v-slot:item.name="{ item }">
<div class="d-flex align-center py-2 cursor-pointer">
<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">ID: {{ item.member_id || `Pending (DB ID: ${item.Id})` }}</div>
</div>
</div>
</template>
<template v-slot:item.email="{ item }">
<a :href="`mailto:${item.email}`" class="text-primary text-decoration-none" @click.stop>
{{ item.email }}
</a>
</template>
<template v-slot:item.nationality="{ item }">
<div class="d-flex align-center">
<MultipleCountryFlags
:nationality="item.nationality"
:show-name="true"
size="small"
fallback-text="Not specified"
/>
</div>
</template>
<template v-slot:item.dues_paid="{ item }">
<div class="d-flex align-center gap-2">
<v-chip
:color="item.dues_paid_this_year ? 'success' : 'warning'"
size="small"
variant="flat"
>
{{ item.dues_paid_this_year ? 'Yes' : 'No' }}
</v-chip>
<v-btn
v-if="!item.dues_paid_this_year"
color="success"
size="small"
variant="tonal"
@click.stop="markDuesPaid(item)"
>
<v-icon start size="16">mdi-check</v-icon>
Mark Paid
</v-btn>
</div>
</template>
<template v-slot:item.actions="{ item }">
<div class="d-flex align-center gap-1">
<v-btn
icon="mdi-eye"
size="small"
variant="text"
@click.stop="viewMember(item)"
>
<v-tooltip activator="parent" location="top">View Details</v-tooltip>
</v-btn>
<v-btn
icon="mdi-pencil"
size="small"
variant="text"
@click.stop="editMember(item)"
>
<v-tooltip activator="parent" location="top">Edit Member</v-tooltip>
</v-btn>
<v-btn
icon="mdi-email"
size="small"
variant="text"
@click.stop="sendEmail(item)"
>
<v-tooltip activator="parent" location="top">Send Email</v-tooltip>
</v-btn>
<v-btn
icon="mdi-dots-vertical"
size="small"
variant="text"
@click.stop
>
<v-menu activator="parent">
<v-list density="compact">
<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>
</div>
</template>
</v-data-table>
</v-card>
<!-- Grid View -->
<v-row v-else-if="viewMode === 'grid'">
<v-col
v-for="member in paginatedGridMembers"
:key="member.member_id"
cols="12"
sm="6"
md="4"
lg="3"
>
<v-card
elevation="2"
class="member-card h-100"
@click="viewMember(member)"
>
<v-card-text class="text-center pt-6 pb-4">
<!-- Profile Avatar -->
<ProfileAvatar
:member-id="member.member_id"
:first-name="member.first_name"
:last-name="member.last_name"
size="80"
class="mb-3 mx-auto elevation-2"
/>
<!-- Member Name and Nationality -->
<h3 class="text-h6 font-weight-bold mb-1">
{{ member.first_name }} {{ member.last_name }}
</h3>
<div class="d-flex align-center justify-center mb-3">
<MultipleCountryFlags
:nationality="member.nationality"
:show-name="true"
size="small"
fallback-text="No nationality"
class="text-body-2 text-medium-emphasis"
/>
</div>
<!-- Email -->
<div class="text-body-2 text-medium-emphasis mb-3">
<v-icon size="small" class="mr-1">mdi-email</v-icon>
{{ member.email }}
</div>
<!-- Status Badges -->
<div class="d-flex justify-center gap-2 mb-3">
<v-chip
:color="member.status === 'active' ? 'success' : 'error'"
size="small"
variant="tonal"
>
{{ member.status }}
</v-chip>
<v-chip
:color="member.dues_paid_this_year ? 'success' : 'warning'"
size="small"
variant="flat"
>
Dues: {{ member.dues_paid_this_year ? 'Paid' : 'Unpaid' }}
</v-chip>
</div>
<!-- Mark as Paid Button -->
<v-btn
v-if="!member.dues_paid_this_year"
color="success"
variant="flat"
block
class="mb-2"
@click.stop="markDuesPaid(member)"
>
<v-icon start>mdi-cash-check</v-icon>
Mark Dues Paid
</v-btn>
<!-- Action Buttons -->
<div class="d-flex justify-center gap-2">
<v-btn
icon="mdi-eye"
size="small"
variant="tonal"
@click.stop="viewMember(member)"
>
<v-tooltip activator="parent" location="top">View</v-tooltip>
</v-btn>
<v-btn
icon="mdi-pencil"
size="small"
variant="tonal"
@click.stop="editMember(member)"
>
<v-tooltip activator="parent" location="top">Edit</v-tooltip>
</v-btn>
<v-btn
icon="mdi-email"
size="small"
variant="tonal"
@click.stop="sendEmail(member)"
>
<v-tooltip activator="parent" location="top">Email</v-tooltip>
</v-btn>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- Grid Pagination -->
<v-card v-if="viewMode === 'grid' && filteredMembers.length > gridItemsPerPage" class="mt-4">
<v-card-text>
<v-pagination
v-model="gridPage"
:length="Math.ceil(filteredMembers.length / gridItemsPerPage)"
:total-visible="7"
/>
</v-card-text>
</v-card>
<!-- View Member Dialog -->
<ViewMemberDialog
v-model="showViewDialog"
:member="selectedMember"
@edit="handleEditMember"
@mark-dues-paid="handleMarkDuesPaid"
/>
<!-- 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-col cols="12">
<v-select
v-model="memberForm.nationality"
label="Nationality"
:items="countryOptions"
item-title="name"
item-value="code"
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';
import { countries } from '~/utils/countries';
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);
const duesFilter = ref(null);
const viewMode = ref('list'); // 'list' or 'grid'
const gridPage = ref(1);
const gridItemsPerPage = 12;
// Stats
const stats = ref({
total: 0,
active: 0,
paidThisYear: 0,
duesOutstanding: 0
});
// Form data
const memberForm = ref({
first_name: '',
last_name: '',
email: '',
membership_type: 'Standard',
phone: '',
nationality: ''
});
// Options
const statusOptions = ['active', 'inactive'];
const membershipOptions = ['Standard', 'Premium', 'VIP', 'Lifetime'];
const countryOptions = countries;
// Enhanced table configuration for new columns
const enhancedHeaders = [
{ title: 'Name', key: 'name', sortable: true },
{ title: 'Email', key: 'email', sortable: true },
{ title: 'Nationality', key: 'nationality', sortable: true },
{ title: 'Dues Paid This Year', key: 'dues_paid', sortable: true },
{ title: 'Actions', key: 'actions', sortable: false, align: 'center', width: '200' }
];
// 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);
}
if (duesFilter.value) {
if (duesFilter.value === 'Paid') {
filtered = filtered.filter(m => m.dues_paid_this_year);
} else if (duesFilter.value === 'Unpaid' || duesFilter.value === 'Overdue') {
filtered = filtered.filter(m => !m.dues_paid_this_year);
}
}
return filtered;
});
// Paginated grid members
const paginatedGridMembers = computed(() => {
const start = (gridPage.value - 1) * gridItemsPerPage;
const end = start + gridItemsPerPage;
return filteredMembers.value.slice(start, end);
});
// Methods
const getCountryName = (code: string) => {
if (!code) return null;
const country = countries.find(c => c.code === code);
return country ? country.name : code;
};
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 markDuesPaid = async (member: Member) => {
try {
// Update member dues status
member.dues_paid_this_year = true;
member.dues_status = 'Paid';
member.last_dues_paid = new Date().toISOString();
// Update stats
stats.value.paidThisYear++;
stats.value.duesOutstanding--;
// TODO: Make API call to update in database
} catch (error) {
console.error('Error marking dues as paid:', error);
}
};
const handleMarkDuesPaid = (member: Member) => {
markDuesPaid(member);
};
const sendEmail = (member: Member) => {
window.location.href = `mailto:${member.email}`;
};
const viewPaymentHistory = (member: Member) => {
// TODO: Navigate to payment history or open dialog
};
const generateInvoice = (member: Member) => {
// TODO: Generate and download invoice
};
const toggleStatus = (member: Member) => {
member.status = member.status === 'active' ? 'inactive' : 'active';
// TODO: Make API call to update status
};
const exportMembers = () => {
// TODO: Export to CSV/Excel
};
const saveMember = () => {
showCreateDialog.value = false;
// TODO: Make API call to create member
};
// Load real members data from API
const loadMembers = async () => {
loading.value = true;
try {
// Fetch members from API
const response = await $fetch('/api/members');
// Check for both possible response structures
const membersList = response?.data?.list || response?.data?.members || response?.list || [];
if (membersList && membersList.length > 0) {
// Transform the data to match our interface with enhanced fields
const currentYear = new Date().getFullYear();
members.value = membersList.map((member: any) => {
// Determine if dues are paid this year (simplified logic)
const lastPaid = member.last_dues_paid ? new Date(member.last_dues_paid) : null;
const duesPaidThisYear = lastPaid && lastPaid.getFullYear() === currentYear;
return {
...member, // Keep all original fields including Id for API calls
member_id: member.member_id || '', // Use the actual member_id field
first_name: member.first_name,
last_name: member.last_name,
name: `${member.last_name || ''}, ${member.first_name || ''}`.trim(),
email: member.email,
nationality: member.nationality || member.country_code || '',
membership_type: member.membership_type || 'Standard',
status: member.membership_status === 'Active' ? 'active' : 'inactive',
dues_status: member.dues_status || (duesPaidThisYear ? 'Paid' : 'Due'),
dues_paid_this_year: duesPaidThisYear,
last_dues_paid: member.last_dues_paid,
membership_date_paid: member.membership_date_paid,
payment_due_date: member.payment_due_date,
join_date: member.member_since || member.created_at,
phone: member.phone_number || member.phone || ''
};
});
// Sort by last name, then first name by default
members.value.sort((a, b) => {
const aLastName = (a.last_name || '').toLowerCase();
const bLastName = (b.last_name || '').toLowerCase();
const aFirstName = (a.first_name || '').toLowerCase();
const bFirstName = (b.first_name || '').toLowerCase();
const lastNameCompare = aLastName.localeCompare(bLastName);
if (lastNameCompare !== 0) return lastNameCompare;
return aFirstName.localeCompare(bFirstName);
});
// Calculate stats from real data
const currentYearMembers = members.value.filter(m => m.dues_paid_this_year);
stats.value = {
total: members.value.length,
active: members.value.filter(m => m.status === 'active').length,
paidThisYear: currentYearMembers.length,
duesOutstanding: members.value.length - currentYearMembers.length
};
} else {
members.value = [];
stats.value = {
total: 0,
active: 0,
paidThisYear: 0,
duesOutstanding: 0
};
}
} catch (error) {
members.value = [];
stats.value = {
total: 0,
active: 0,
paidThisYear: 0,
duesOutstanding: 0
};
} finally {
loading.value = false;
}
};
// Load data on mount
onMounted(async () => {
await loadMembers();
});
</script>
<style scoped>
.member-list-table :deep(tbody tr) {
cursor: pointer;
}
.member-list-table :deep(tbody tr:hover) {
background-color: rgba(var(--v-theme-primary), 0.04);
}
.member-card {
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
}
.member-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15);
}
.cursor-pointer {
cursor: pointer;
}
</style>