Fix member management issues and add refined view
Build And Push Image / docker (push) Successful in 2m0s
Details
Build And Push Image / docker (push) Successful in 2m0s
Details
- Sort dues management cards alphabetically by last name
- Change 'Invalid Date' display to 'N/A' in formatDate functions
- Add new refined member management view with modern UI design
- Glassmorphism effects and gradient accents
- Enhanced stat cards with progress indicators
- Improved search and filter interface
- Better card and table layouts
- Smooth animations and transitions
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
183bba0c9e
commit
12469a7952
|
|
@ -150,8 +150,21 @@ const loadDuesData = async () => {
|
|||
}>('/api/members/dues-status');
|
||||
|
||||
if (response.success) {
|
||||
overdueMembers.value = response.data.overdue || [];
|
||||
upcomingMembers.value = response.data.upcoming || [];
|
||||
// Sort members alphabetically by last name, then first name
|
||||
const sortByName = (a: Member, b: Member) => {
|
||||
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);
|
||||
};
|
||||
|
||||
overdueMembers.value = (response.data.overdue || []).sort(sortByName);
|
||||
upcomingMembers.value = (response.data.upcoming || []).sort(sortByName);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading dues data:', error);
|
||||
|
|
|
|||
|
|
@ -570,8 +570,10 @@ const getMembershipColor = (type: string) => {
|
|||
};
|
||||
|
||||
const formatDate = (date: string) => {
|
||||
if (!date) return null;
|
||||
return new Date(date).toLocaleDateString('en-US', {
|
||||
if (!date) return 'N/A';
|
||||
const parsedDate = new Date(date);
|
||||
if (isNaN(parsedDate.getTime())) return 'N/A';
|
||||
return parsedDate.toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
|
|
|
|||
|
|
@ -0,0 +1,996 @@
|
|||
<template>
|
||||
<v-container fluid class="pa-6">
|
||||
<!-- Animated Header with Gradient -->
|
||||
<div class="header-section mb-8">
|
||||
<v-row align="center" justify="space-between">
|
||||
<v-col cols="auto">
|
||||
<div class="d-flex align-center">
|
||||
<v-avatar size="56" class="gradient-avatar mr-4 elevation-3">
|
||||
<v-icon size="32" color="white">mdi-account-group</v-icon>
|
||||
</v-avatar>
|
||||
<div>
|
||||
<h1 class="text-h3 font-weight-bold gradient-text">Member Directory</h1>
|
||||
<p class="text-body-1 text-medium-emphasis mt-1">
|
||||
<v-icon size="18" class="mr-1">mdi-account-multiple</v-icon>
|
||||
{{ stats.total }} total members • {{ stats.active }} active
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</v-col>
|
||||
<v-col cols="auto">
|
||||
<v-btn
|
||||
color="primary"
|
||||
size="large"
|
||||
elevation="3"
|
||||
rounded="lg"
|
||||
prepend-icon="mdi-account-plus"
|
||||
@click="showCreateDialog = true"
|
||||
class="pulse-animation"
|
||||
>
|
||||
Add New Member
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</div>
|
||||
|
||||
<!-- Enhanced Stats Cards with Glassmorphism -->
|
||||
<v-row class="mb-8">
|
||||
<v-col v-for="stat in statsCards" :key="stat.title" cols="12" sm="6" md="3">
|
||||
<v-card
|
||||
class="stat-card glass-card"
|
||||
elevation="0"
|
||||
:style="`border-left: 4px solid ${stat.color}`"
|
||||
>
|
||||
<v-card-text class="pa-5">
|
||||
<div class="d-flex align-center justify-space-between mb-3">
|
||||
<v-avatar :color="stat.color" size="48" class="elevation-2">
|
||||
<v-icon color="white">{{ stat.icon }}</v-icon>
|
||||
</v-avatar>
|
||||
<v-chip
|
||||
v-if="stat.change"
|
||||
:color="stat.changeType === 'increase' ? 'success' : 'error'"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
>
|
||||
<v-icon size="14">
|
||||
{{ stat.changeType === 'increase' ? 'mdi-trending-up' : 'mdi-trending-down' }}
|
||||
</v-icon>
|
||||
{{ stat.change }}
|
||||
</v-chip>
|
||||
</div>
|
||||
<div class="text-h3 font-weight-bold mb-1">{{ stat.value }}</div>
|
||||
<div class="text-body-2 text-medium-emphasis">{{ stat.title }}</div>
|
||||
<v-progress-linear
|
||||
v-if="stat.progress"
|
||||
:model-value="stat.progress"
|
||||
:color="stat.color"
|
||||
height="4"
|
||||
rounded
|
||||
class="mt-3"
|
||||
/>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Enhanced Search & Filters Bar -->
|
||||
<v-card class="filter-card glass-card mb-6" elevation="0">
|
||||
<v-card-text class="pa-5">
|
||||
<v-row align="center">
|
||||
<v-col cols="12" md="5">
|
||||
<v-text-field
|
||||
v-model="searchQuery"
|
||||
label="Search members"
|
||||
placeholder="Name, email, or ID..."
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
variant="solo"
|
||||
density="comfortable"
|
||||
clearable
|
||||
hide-details
|
||||
class="search-field"
|
||||
>
|
||||
<template v-slot:append-inner>
|
||||
<v-badge
|
||||
v-if="searchQuery"
|
||||
:content="filteredMembers.length"
|
||||
color="primary"
|
||||
inline
|
||||
/>
|
||||
</template>
|
||||
</v-text-field>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" md="7">
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<v-chip-group
|
||||
v-model="quickFilter"
|
||||
selected-class="chip-active"
|
||||
>
|
||||
<v-chip filter variant="outlined" value="all">
|
||||
<v-icon start size="18">mdi-all-inclusive</v-icon>
|
||||
All Members
|
||||
</v-chip>
|
||||
<v-chip filter variant="outlined" value="active">
|
||||
<v-icon start size="18" color="success">mdi-check-circle</v-icon>
|
||||
Active
|
||||
</v-chip>
|
||||
<v-chip filter variant="outlined" value="dues-pending">
|
||||
<v-icon start size="18" color="warning">mdi-clock-alert</v-icon>
|
||||
Dues Pending
|
||||
</v-chip>
|
||||
<v-chip filter variant="outlined" value="new">
|
||||
<v-icon start size="18" color="info">mdi-new-box</v-icon>
|
||||
New This Month
|
||||
</v-chip>
|
||||
</v-chip-group>
|
||||
|
||||
<v-btn
|
||||
icon
|
||||
variant="outlined"
|
||||
@click="showAdvancedFilters = !showAdvancedFilters"
|
||||
>
|
||||
<v-icon>mdi-filter-variant</v-icon>
|
||||
<v-tooltip activator="parent">Advanced Filters</v-tooltip>
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
icon
|
||||
variant="outlined"
|
||||
@click="exportMembers"
|
||||
>
|
||||
<v-icon>mdi-download</v-icon>
|
||||
<v-tooltip activator="parent">Export</v-tooltip>
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Advanced Filters (Collapsible) -->
|
||||
<v-expand-transition>
|
||||
<v-row v-if="showAdvancedFilters" class="mt-4">
|
||||
<v-col cols="12" md="3">
|
||||
<v-select
|
||||
v-model="statusFilter"
|
||||
label="Status"
|
||||
:items="statusOptions"
|
||||
variant="solo"
|
||||
density="comfortable"
|
||||
clearable
|
||||
hide-details
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-select
|
||||
v-model="membershipFilter"
|
||||
label="Membership Type"
|
||||
:items="membershipOptions"
|
||||
variant="solo"
|
||||
density="comfortable"
|
||||
clearable
|
||||
hide-details
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-select
|
||||
v-model="nationalityFilter"
|
||||
label="Nationality"
|
||||
:items="countryOptions"
|
||||
item-title="name"
|
||||
item-value="code"
|
||||
variant="solo"
|
||||
density="comfortable"
|
||||
clearable
|
||||
hide-details
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<v-select
|
||||
v-model="duesFilter"
|
||||
label="Dues Status"
|
||||
:items="['Paid', 'Unpaid', 'Overdue']"
|
||||
variant="solo"
|
||||
density="comfortable"
|
||||
clearable
|
||||
hide-details
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-expand-transition>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<!-- View Mode Toggle -->
|
||||
<div class="d-flex justify-space-between align-center mb-4">
|
||||
<div class="text-body-1">
|
||||
Showing <strong>{{ filteredMembers.length }}</strong> of {{ members.length }} members
|
||||
</div>
|
||||
<v-btn-toggle
|
||||
v-model="viewMode"
|
||||
mandatory
|
||||
density="comfortable"
|
||||
rounded="lg"
|
||||
color="primary"
|
||||
class="elevation-2"
|
||||
>
|
||||
<v-btn value="cards" prepend-icon="mdi-view-grid">
|
||||
Cards
|
||||
</v-btn>
|
||||
<v-btn value="table" prepend-icon="mdi-table">
|
||||
Table
|
||||
</v-btn>
|
||||
</v-btn-toggle>
|
||||
</div>
|
||||
|
||||
<!-- Enhanced Card View -->
|
||||
<transition-group
|
||||
v-if="viewMode === 'cards'"
|
||||
name="card-list"
|
||||
tag="div"
|
||||
class="row"
|
||||
>
|
||||
<v-col
|
||||
v-for="member in paginatedMembers"
|
||||
:key="member.member_id"
|
||||
cols="12"
|
||||
sm="6"
|
||||
md="4"
|
||||
lg="3"
|
||||
>
|
||||
<v-card
|
||||
class="member-card glass-card h-100"
|
||||
elevation="0"
|
||||
@click="viewMember(member)"
|
||||
>
|
||||
<!-- Card Header with Gradient Background -->
|
||||
<div class="card-header gradient-bg pa-4 text-center">
|
||||
<ProfileAvatar
|
||||
:member-id="member.member_id"
|
||||
:first-name="member.first_name"
|
||||
:last-name="member.last_name"
|
||||
size="80"
|
||||
class="mb-3 mx-auto elevation-4 white-border"
|
||||
/>
|
||||
<h3 class="text-h6 font-weight-bold white--text">
|
||||
{{ member.first_name }} {{ member.last_name }}
|
||||
</h3>
|
||||
<div class="text-caption white--text opacity-90">
|
||||
{{ member.member_id || 'Pending ID' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-card-text class="pa-4">
|
||||
<!-- Contact Info -->
|
||||
<div class="info-row mb-3">
|
||||
<v-icon size="18" class="mr-2 text-medium-emphasis">mdi-email</v-icon>
|
||||
<span class="text-body-2 text-truncate">{{ member.email }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Nationality with Flag -->
|
||||
<div class="info-row mb-3">
|
||||
<v-icon size="18" class="mr-2 text-medium-emphasis">mdi-flag</v-icon>
|
||||
<MultipleCountryFlags
|
||||
:nationality="member.nationality"
|
||||
:show-name="true"
|
||||
size="small"
|
||||
fallback-text="Not specified"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Member Since -->
|
||||
<div class="info-row mb-3">
|
||||
<v-icon size="18" class="mr-2 text-medium-emphasis">mdi-calendar</v-icon>
|
||||
<span class="text-body-2">Since {{ formatDate(member.join_date) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Status Badges -->
|
||||
<div class="d-flex flex-wrap gap-2 mb-3">
|
||||
<v-chip
|
||||
:color="member.status === 'active' ? 'success' : 'error'"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
label
|
||||
>
|
||||
<v-icon start size="14">
|
||||
{{ member.status === 'active' ? 'mdi-check' : 'mdi-close' }}
|
||||
</v-icon>
|
||||
{{ member.status }}
|
||||
</v-chip>
|
||||
|
||||
<v-chip
|
||||
:color="getDuesChipColor(member)"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
label
|
||||
>
|
||||
<v-icon start size="14">mdi-cash</v-icon>
|
||||
{{ member.dues_paid_this_year ? 'Dues Paid' : 'Dues Pending' }}
|
||||
</v-chip>
|
||||
|
||||
<v-chip
|
||||
v-if="member.membership_type !== 'Standard'"
|
||||
color="purple"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
label
|
||||
>
|
||||
{{ member.membership_type }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<v-card-actions class="pa-3 pt-0">
|
||||
<v-btn
|
||||
v-if="!member.dues_paid_this_year"
|
||||
color="success"
|
||||
variant="flat"
|
||||
size="small"
|
||||
block
|
||||
rounded
|
||||
@click.stop="markDuesPaid(member)"
|
||||
>
|
||||
<v-icon start>mdi-check</v-icon>
|
||||
Mark Dues Paid
|
||||
</v-btn>
|
||||
<v-row v-else dense>
|
||||
<v-col cols="4">
|
||||
<v-btn
|
||||
icon="mdi-eye"
|
||||
size="small"
|
||||
variant="text"
|
||||
block
|
||||
@click.stop="viewMember(member)"
|
||||
>
|
||||
<v-tooltip activator="parent">View</v-tooltip>
|
||||
</v-btn>
|
||||
</v-col>
|
||||
<v-col cols="4">
|
||||
<v-btn
|
||||
icon="mdi-pencil"
|
||||
size="small"
|
||||
variant="text"
|
||||
block
|
||||
@click.stop="editMember(member)"
|
||||
>
|
||||
<v-tooltip activator="parent">Edit</v-tooltip>
|
||||
</v-btn>
|
||||
</v-col>
|
||||
<v-col cols="4">
|
||||
<v-btn
|
||||
icon="mdi-email"
|
||||
size="small"
|
||||
variant="text"
|
||||
block
|
||||
@click.stop="sendEmail(member)"
|
||||
>
|
||||
<v-tooltip activator="parent">Email</v-tooltip>
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</transition-group>
|
||||
|
||||
<!-- Enhanced Table View -->
|
||||
<v-card v-else-if="viewMode === 'table'" class="glass-card" elevation="0">
|
||||
<v-data-table
|
||||
:headers="tableHeaders"
|
||||
:items="filteredMembers"
|
||||
:search="searchQuery"
|
||||
:loading="loading"
|
||||
class="modern-table"
|
||||
hover
|
||||
:items-per-page="15"
|
||||
@click:row="(e, { item }) => viewMember(item)"
|
||||
>
|
||||
<template v-slot:item.member="{ item }">
|
||||
<div class="d-flex align-center py-3">
|
||||
<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.member_id || 'Pending ID' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.contact="{ item }">
|
||||
<div class="py-2">
|
||||
<div class="d-flex align-center mb-1">
|
||||
<v-icon size="14" class="mr-1">mdi-email</v-icon>
|
||||
<a :href="`mailto:${item.email}`" class="text-primary text-decoration-none" @click.stop>
|
||||
{{ item.email }}
|
||||
</a>
|
||||
</div>
|
||||
<div v-if="item.phone" class="d-flex align-center text-caption">
|
||||
<v-icon size="14" class="mr-1">mdi-phone</v-icon>
|
||||
{{ item.phone }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.nationality="{ item }">
|
||||
<MultipleCountryFlags
|
||||
:nationality="item.nationality"
|
||||
:show-name="true"
|
||||
size="small"
|
||||
fallback-text="—"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.membership="{ item }">
|
||||
<div class="py-2">
|
||||
<v-chip
|
||||
:color="getMembershipColor(item.membership_type)"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
label
|
||||
class="mb-1"
|
||||
>
|
||||
{{ item.membership_type }}
|
||||
</v-chip>
|
||||
<div class="text-caption text-medium-emphasis">
|
||||
Since {{ formatDate(item.join_date) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.status="{ item }">
|
||||
<div class="d-flex gap-2">
|
||||
<v-chip
|
||||
:color="item.status === 'active' ? 'success' : 'error'"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
label
|
||||
>
|
||||
{{ item.status }}
|
||||
</v-chip>
|
||||
<v-chip
|
||||
:color="getDuesChipColor(item)"
|
||||
size="small"
|
||||
variant="tonal"
|
||||
label
|
||||
>
|
||||
{{ item.dues_paid_this_year ? 'Paid' : 'Due' }}
|
||||
</v-chip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-slot:item.actions="{ item }">
|
||||
<div class="d-flex gap-1">
|
||||
<v-btn
|
||||
icon="mdi-eye"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
@click.stop="viewMember(item)"
|
||||
/>
|
||||
<v-btn
|
||||
icon="mdi-pencil"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
@click.stop="editMember(item)"
|
||||
/>
|
||||
<v-menu>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn
|
||||
icon="mdi-dots-vertical"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
v-bind="props"
|
||||
@click.stop
|
||||
/>
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<v-list-item @click="sendEmail(item)">
|
||||
<template v-slot:prepend>
|
||||
<v-icon size="small">mdi-email</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>Send Email</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item
|
||||
v-if="!item.dues_paid_this_year"
|
||||
@click="markDuesPaid(item)"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon size="small" color="success">mdi-check</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>Mark Dues Paid</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-list-item @click="viewPaymentHistory(item)">
|
||||
<template v-slot:prepend>
|
||||
<v-icon size="small">mdi-history</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>Payment History</v-list-item-title>
|
||||
</v-list-item>
|
||||
<v-divider />
|
||||
<v-list-item
|
||||
@click="toggleStatus(item)"
|
||||
:class="item.status === 'active' ? 'text-error' : 'text-success'"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon size="small">
|
||||
{{ item.status === 'active' ? 'mdi-account-off' : 'mdi-account-check' }}
|
||||
</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>
|
||||
{{ item.status === 'active' ? 'Deactivate' : 'Activate' }}
|
||||
</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</div>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card>
|
||||
|
||||
<!-- Pagination -->
|
||||
<v-card
|
||||
v-if="viewMode === 'cards' && filteredMembers.length > itemsPerPage"
|
||||
class="mt-6 glass-card"
|
||||
elevation="0"
|
||||
>
|
||||
<v-card-text>
|
||||
<v-pagination
|
||||
v-model="currentPage"
|
||||
:length="Math.ceil(filteredMembers.length / itemsPerPage)"
|
||||
:total-visible="7"
|
||||
rounded="circle"
|
||||
color="primary"
|
||||
/>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<!-- Dialogs -->
|
||||
<ViewMemberDialog
|
||||
v-model="showViewDialog"
|
||||
:member="selectedMember"
|
||||
@edit="handleEditMember"
|
||||
@mark-dues-paid="handleMarkDuesPaid"
|
||||
/>
|
||||
|
||||
<EditMemberDialog
|
||||
v-model="showEditDialog"
|
||||
:member="selectedMember"
|
||||
@member-updated="handleMemberUpdated"
|
||||
/>
|
||||
</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 members = ref<Member[]>([]);
|
||||
const searchQuery = ref('');
|
||||
const quickFilter = ref('all');
|
||||
const statusFilter = ref(null);
|
||||
const membershipFilter = ref(null);
|
||||
const nationalityFilter = ref(null);
|
||||
const duesFilter = ref(null);
|
||||
const viewMode = ref('cards');
|
||||
const currentPage = ref(1);
|
||||
const itemsPerPage = 12;
|
||||
const showAdvancedFilters = ref(false);
|
||||
const showViewDialog = ref(false);
|
||||
const showEditDialog = ref(false);
|
||||
const showCreateDialog = ref(false);
|
||||
const selectedMember = ref<Member | null>(null);
|
||||
|
||||
// Stats
|
||||
const stats = ref({
|
||||
total: 0,
|
||||
active: 0,
|
||||
paidThisYear: 0,
|
||||
duesOutstanding: 0,
|
||||
newThisMonth: 0
|
||||
});
|
||||
|
||||
// Computed stats cards
|
||||
const statsCards = computed(() => [
|
||||
{
|
||||
title: 'Total Members',
|
||||
value: stats.value.total,
|
||||
icon: 'mdi-account-group',
|
||||
color: '#3b82f6',
|
||||
change: '+12',
|
||||
changeType: 'increase'
|
||||
},
|
||||
{
|
||||
title: 'Active Members',
|
||||
value: stats.value.active,
|
||||
icon: 'mdi-account-check',
|
||||
color: '#10b981',
|
||||
progress: Math.round((stats.value.active / stats.value.total) * 100)
|
||||
},
|
||||
{
|
||||
title: 'Dues Paid',
|
||||
value: stats.value.paidThisYear,
|
||||
icon: 'mdi-cash-check',
|
||||
color: '#8b5cf6',
|
||||
progress: Math.round((stats.value.paidThisYear / stats.value.total) * 100)
|
||||
},
|
||||
{
|
||||
title: 'New This Month',
|
||||
value: stats.value.newThisMonth,
|
||||
icon: 'mdi-account-plus',
|
||||
color: '#f59e0b',
|
||||
change: '+8',
|
||||
changeType: 'increase'
|
||||
}
|
||||
]);
|
||||
|
||||
// Options
|
||||
const statusOptions = ['active', 'inactive'];
|
||||
const membershipOptions = ['Standard', 'Premium', 'VIP', 'Lifetime'];
|
||||
const countryOptions = countries;
|
||||
|
||||
// Table headers
|
||||
const tableHeaders = [
|
||||
{ title: 'Member', key: 'member', sortable: true },
|
||||
{ title: 'Contact', key: 'contact', sortable: true },
|
||||
{ title: 'Nationality', key: 'nationality', sortable: true },
|
||||
{ title: 'Membership', key: 'membership', sortable: true },
|
||||
{ title: 'Status', key: 'status', sortable: true },
|
||||
{ title: '', key: 'actions', sortable: false, align: 'end' }
|
||||
];
|
||||
|
||||
// Computed
|
||||
const filteredMembers = computed(() => {
|
||||
let filtered = [...members.value];
|
||||
|
||||
// Apply quick filter
|
||||
if (quickFilter.value === 'active') {
|
||||
filtered = filtered.filter(m => m.status === 'active');
|
||||
} else if (quickFilter.value === 'dues-pending') {
|
||||
filtered = filtered.filter(m => !m.dues_paid_this_year);
|
||||
} else if (quickFilter.value === 'new') {
|
||||
const thisMonth = new Date().getMonth();
|
||||
filtered = filtered.filter(m => {
|
||||
const joinDate = new Date(m.join_date);
|
||||
return joinDate.getMonth() === thisMonth;
|
||||
});
|
||||
}
|
||||
|
||||
// Apply advanced filters
|
||||
if (statusFilter.value) {
|
||||
filtered = filtered.filter(m => m.status === statusFilter.value);
|
||||
}
|
||||
|
||||
if (membershipFilter.value) {
|
||||
filtered = filtered.filter(m => m.membership_type === membershipFilter.value);
|
||||
}
|
||||
|
||||
if (nationalityFilter.value) {
|
||||
filtered = filtered.filter(m => m.nationality === nationalityFilter.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;
|
||||
});
|
||||
|
||||
const paginatedMembers = computed(() => {
|
||||
const start = (currentPage.value - 1) * itemsPerPage;
|
||||
const end = start + itemsPerPage;
|
||||
return filteredMembers.value.slice(start, end);
|
||||
});
|
||||
|
||||
// Methods
|
||||
const formatDate = (date: string) => {
|
||||
if (!date) return 'N/A';
|
||||
const parsedDate = new Date(date);
|
||||
if (isNaN(parsedDate.getTime())) return 'N/A';
|
||||
return parsedDate.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const getMembershipColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'VIP': return 'error';
|
||||
case 'Premium': return 'warning';
|
||||
case 'Lifetime': return 'purple';
|
||||
default: return 'info';
|
||||
}
|
||||
};
|
||||
|
||||
const getDuesChipColor = (member: Member) => {
|
||||
return member.dues_paid_this_year ? 'success' : 'warning';
|
||||
};
|
||||
|
||||
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 {
|
||||
member.dues_paid_this_year = true;
|
||||
member.dues_status = 'Paid';
|
||||
member.last_dues_paid = new Date().toISOString();
|
||||
|
||||
stats.value.paidThisYear++;
|
||||
stats.value.duesOutstanding--;
|
||||
} 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
|
||||
};
|
||||
|
||||
const toggleStatus = (member: Member) => {
|
||||
member.status = member.status === 'active' ? 'inactive' : 'active';
|
||||
// TODO: Make API call
|
||||
};
|
||||
|
||||
const exportMembers = () => {
|
||||
// TODO: Export to CSV/Excel
|
||||
};
|
||||
|
||||
// Load members data
|
||||
const loadMembers = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await $fetch('/api/members');
|
||||
const membersList = response?.data?.list || response?.data?.members || response?.list || [];
|
||||
|
||||
if (membersList && membersList.length > 0) {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const currentMonth = new Date().getMonth();
|
||||
|
||||
members.value = membersList.map((member: any) => {
|
||||
const lastPaid = member.last_dues_paid ? new Date(member.last_dues_paid) : null;
|
||||
const duesPaidThisYear = lastPaid && lastPaid.getFullYear() === currentYear;
|
||||
const joinDate = member.member_since || member.created_at;
|
||||
const joinMonth = joinDate ? new Date(joinDate).getMonth() : null;
|
||||
|
||||
return {
|
||||
...member,
|
||||
member_id: member.member_id || '',
|
||||
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,
|
||||
join_date: joinDate,
|
||||
phone: member.phone_number || member.phone || ''
|
||||
};
|
||||
}).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
|
||||
const currentYearMembers = members.value.filter(m => m.dues_paid_this_year);
|
||||
const newThisMonth = members.value.filter(m => {
|
||||
const joinDate = new Date(m.join_date);
|
||||
return joinDate.getMonth() === currentMonth && joinDate.getFullYear() === currentYear;
|
||||
});
|
||||
|
||||
stats.value = {
|
||||
total: members.value.length,
|
||||
active: members.value.filter(m => m.status === 'active').length,
|
||||
paidThisYear: currentYearMembers.length,
|
||||
duesOutstanding: members.value.length - currentYearMembers.length,
|
||||
newThisMonth: newThisMonth.length
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading members:', error);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Load on mount
|
||||
onMounted(async () => {
|
||||
await loadMembers();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Glassmorphism effect */
|
||||
.glass-card {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 16px !important;
|
||||
}
|
||||
|
||||
/* Gradient text */
|
||||
.gradient-text {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* Gradient avatar */
|
||||
.gradient-avatar {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
/* Gradient background for card headers */
|
||||
.gradient-bg {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
/* Stat card hover effect */
|
||||
.stat-card {
|
||||
transition: all 0.3s ease;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Member card effects */
|
||||
.member-card {
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.member-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.member-card .card-header {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.member-card .card-header::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(45deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.5s;
|
||||
}
|
||||
|
||||
.member-card:hover .card-header::before {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
/* White border for avatar */
|
||||
.white-border {
|
||||
border: 3px solid white;
|
||||
}
|
||||
|
||||
/* Info row styling */
|
||||
.info-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Search field styling */
|
||||
.search-field :deep(.v-field) {
|
||||
border-radius: 12px !important;
|
||||
}
|
||||
|
||||
/* Chip active state */
|
||||
.chip-active {
|
||||
background-color: rgba(var(--v-theme-primary), 0.12) !important;
|
||||
border-color: rgb(var(--v-theme-primary)) !important;
|
||||
}
|
||||
|
||||
/* Table styling */
|
||||
.modern-table :deep(tbody tr) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.modern-table :deep(tbody tr:hover) {
|
||||
background-color: rgba(var(--v-theme-primary), 0.04);
|
||||
}
|
||||
|
||||
/* Animation for cards */
|
||||
.card-list-move,
|
||||
.card-list-enter-active,
|
||||
.card-list-leave-active {
|
||||
transition: all 0.5s ease;
|
||||
}
|
||||
|
||||
.card-list-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
}
|
||||
|
||||
.card-list-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-30px);
|
||||
}
|
||||
|
||||
/* Pulse animation for add button */
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 rgba(var(--v-theme-primary), 0.4);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 10px rgba(var(--v-theme-primary), 0);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(var(--v-theme-primary), 0);
|
||||
}
|
||||
}
|
||||
|
||||
.pulse-animation {
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
/* Filter card styling */
|
||||
.filter-card {
|
||||
border-left: 4px solid rgb(var(--v-theme-primary));
|
||||
}
|
||||
</style>
|
||||
|
|
@ -607,7 +607,10 @@ const getDuesColor = (status: string) => {
|
|||
};
|
||||
|
||||
const formatDate = (date: string) => {
|
||||
return new Date(date).toLocaleDateString('en-US', {
|
||||
if (!date) return 'N/A';
|
||||
const parsedDate = new Date(date);
|
||||
if (isNaN(parsedDate.getTime())) return 'N/A';
|
||||
return parsedDate.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
|
|
|
|||
Loading…
Reference in New Issue