Add dues management system with UI improvements
Build And Push Image / docker (push) Successful in 3m3s Details

- Add BoardDuesManagement and DuesActionCard components
- Create API endpoints for dues status tracking and payment marking
- Integrate dues management section into board dashboard
- Move create portal account button to member card action buttons
- Add edit button to member view dialog
- Implement member update handlers and navigation between views
This commit is contained in:
Matt 2025-08-10 23:19:48 +02:00
parent 91dea9910d
commit d3c3a865ba
8 changed files with 669 additions and 17 deletions

View File

@ -0,0 +1,221 @@
<template>
<v-card elevation="2" class="dues-management-card">
<v-card-title class="pa-4 bg-warning-lighten-5">
<v-icon class="mr-2" color="warning">mdi-cash-clock</v-icon>
<span class="text-h6">Dues Management</span>
<v-spacer />
<v-chip color="warning" size="small">
{{ overdueMembers.length + upcomingMembers.length }} Action Items
</v-chip>
</v-card-title>
<v-card-text class="pa-4">
<v-tabs v-model="activeTab" color="primary" class="mb-4">
<v-tab value="overdue">
<v-icon start>mdi-alert-circle</v-icon>
Overdue ({{ overdueMembers.length }})
</v-tab>
<v-tab value="upcoming">
<v-icon start>mdi-clock-alert</v-icon>
Due Soon ({{ upcomingMembers.length }})
</v-tab>
</v-tabs>
<v-tabs-window v-model="activeTab">
<!-- Overdue Dues Tab -->
<v-tabs-window-item value="overdue">
<div v-if="overdueMembers.length === 0" class="text-center py-6">
<v-icon size="48" color="success" class="mb-2">mdi-check-circle</v-icon>
<p class="text-h6 text-success">All caught up!</p>
<p class="text-body-2">No members have overdue dues.</p>
</div>
<v-row v-else>
<v-col
v-for="member in overdueMembers"
:key="member.Id"
cols="12"
md="6"
lg="4"
>
<DuesActionCard
:member="member"
status="overdue"
@mark-paid="handleMarkPaid"
@view-member="$emit('view-member', member)"
:loading="loading[member.Id]"
/>
</v-col>
</v-row>
</v-tabs-window-item>
<!-- Upcoming Dues Tab -->
<v-tabs-window-item value="upcoming">
<div v-if="upcomingMembers.length === 0" class="text-center py-6">
<v-icon size="48" color="info" class="mb-2">mdi-calendar-check</v-icon>
<p class="text-h6 text-info">All up to date!</p>
<p class="text-body-2">No upcoming dues in the next 30 days.</p>
</div>
<v-row v-else>
<v-col
v-for="member in upcomingMembers"
:key="member.Id"
cols="12"
md="6"
lg="4"
>
<DuesActionCard
:member="member"
status="upcoming"
@mark-paid="handleMarkPaid"
@view-member="$emit('view-member', member)"
:loading="loading[member.Id]"
/>
</v-col>
</v-row>
</v-tabs-window-item>
</v-tabs-window>
</v-card-text>
<!-- Refresh Button -->
<v-card-actions class="pa-4">
<v-btn
color="primary"
variant="outlined"
:loading="refreshLoading"
@click="refreshData"
>
<v-icon start>mdi-refresh</v-icon>
Refresh
</v-btn>
<v-spacer />
<v-btn
color="primary"
variant="text"
@click="$emit('view-all-members')"
>
<v-icon start>mdi-account-group</v-icon>
View All Members
</v-btn>
</v-card-actions>
</v-card>
</template>
<script setup lang="ts">
import type { Member } from '~/utils/types';
interface Props {
refreshTrigger?: number;
}
interface Emits {
(e: 'view-member', member: Member): void;
(e: 'view-all-members'): void;
(e: 'member-updated', member: Member): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
// State
const activeTab = ref('overdue');
const overdueMembers = ref<Member[]>([]);
const upcomingMembers = ref<Member[]>([]);
const loading = ref<Record<string, boolean>>({});
const refreshLoading = ref(false);
// Load dues data
const loadDuesData = async () => {
refreshLoading.value = true;
try {
const response = await $fetch<{
success: boolean;
data: {
overdue: Member[];
upcoming: Member[];
};
}>('/api/members/dues-status');
if (response.success) {
overdueMembers.value = response.data.overdue || [];
upcomingMembers.value = response.data.upcoming || [];
}
} catch (error) {
console.error('Error loading dues data:', error);
// Show error notification
} finally {
refreshLoading.value = false;
}
};
// Handle mark as paid
const handleMarkPaid = async (member: Member) => {
loading.value[member.Id] = true;
try {
const response = await $fetch<{
success: boolean;
data: Member;
message?: string;
}>(`/api/members/${member.Id}/mark-dues-paid`, {
method: 'POST'
});
if (response.success) {
// Remove member from current lists
overdueMembers.value = overdueMembers.value.filter(m => m.Id !== member.Id);
upcomingMembers.value = upcomingMembers.value.filter(m => m.Id !== member.Id);
// Emit update event
emit('member-updated', response.data);
// Show success message
console.log('Dues marked as paid successfully');
} else {
throw new Error(response.message || 'Failed to mark dues as paid');
}
} catch (error: any) {
console.error('Error marking dues as paid:', error);
// Show error notification
} finally {
loading.value[member.Id] = false;
}
};
// Refresh data
const refreshData = () => {
loadDuesData();
};
// Watch for refresh trigger
watch(() => props.refreshTrigger, () => {
if (props.refreshTrigger) {
loadDuesData();
}
});
// Load data on mount
onMounted(() => {
loadDuesData();
});
</script>
<style scoped>
.dues-management-card {
border-radius: 12px !important;
}
.bg-warning-lighten-5 {
background-color: rgb(var(--v-theme-warning-lighten-5)) !important;
}
.v-tab {
text-transform: none !important;
}
.v-card-title {
border-bottom: 1px solid rgba(var(--v-theme-outline), 0.12);
}
</style>

View File

@ -0,0 +1,256 @@
<template>
<v-card
:class="[
'dues-action-card',
status === 'overdue' ? 'dues-action-card--overdue' : 'dues-action-card--upcoming'
]"
elevation="2"
>
<!-- Status Badge -->
<div class="status-badge">
<v-chip
:color="statusColor"
size="small"
variant="flat"
>
<v-icon start size="12">{{ statusIcon }}</v-icon>
{{ statusText }}
</v-chip>
</div>
<v-card-text class="pa-4">
<!-- Member Info Header -->
<div class="d-flex align-center mb-3">
<v-avatar
:color="avatarColor"
size="40"
class="mr-3"
>
<span class="text-white font-weight-bold">
{{ memberInitials }}
</span>
</v-avatar>
<div class="flex-grow-1">
<h4 class="text-subtitle-1 font-weight-bold mb-1">
{{ member.FullName || `${member.first_name} ${member.last_name}` }}
</h4>
<div class="d-flex align-center">
<v-chip size="x-small" color="grey" variant="text" class="pa-0 mr-2">
ID: {{ member.member_id || `MUSA-${member.Id}` }}
</v-chip>
<CountryFlag
v-if="member.nationality"
:country-code="member.nationality.split(',')[0]"
:show-name="false"
size="small"
/>
</div>
</div>
</div>
<!-- Dues Information -->
<div class="dues-info mb-3">
<div class="d-flex justify-space-between align-center mb-2">
<span class="text-body-2 text-medium-emphasis">
<v-icon size="14" class="mr-1">mdi-calendar</v-icon>
{{ status === 'overdue' ? 'Was Due' : 'Due Date' }}
</span>
<span class="text-body-2 font-weight-bold" :class="status === 'overdue' ? 'text-error' : 'text-warning'">
{{ formatDate(member.payment_due_date) }}
</span>
</div>
<div v-if="daysDifference !== null" class="d-flex justify-space-between align-center">
<span class="text-body-2 text-medium-emphasis">
<v-icon size="14" class="mr-1">{{ status === 'overdue' ? 'mdi-clock-alert' : 'mdi-clock' }}</v-icon>
{{ status === 'overdue' ? 'Days Overdue' : 'Days Until Due' }}
</span>
<span class="text-body-2 font-weight-bold" :class="status === 'overdue' ? 'text-error' : 'text-warning'">
{{ Math.abs(daysDifference) }} days
</span>
</div>
</div>
<!-- Contact Info -->
<div class="contact-info mb-3">
<div v-if="member.email" class="d-flex align-center mb-1">
<v-icon size="14" class="mr-2 text-medium-emphasis">mdi-email</v-icon>
<span class="text-body-2 text-truncate">{{ member.email }}</span>
</div>
<div v-if="member.phone" class="d-flex align-center">
<v-icon size="14" class="mr-2 text-medium-emphasis">mdi-phone</v-icon>
<span class="text-body-2">{{ member.FormattedPhone || member.phone }}</span>
</div>
</div>
</v-card-text>
<!-- Action Buttons -->
<v-card-actions class="pa-4 pt-0">
<v-btn
color="success"
variant="elevated"
size="small"
:loading="loading"
@click="$emit('mark-paid', member)"
block
>
<v-icon start size="16">mdi-check-circle</v-icon>
Mark as Paid
</v-btn>
</v-card-actions>
<!-- Quick Actions -->
<v-card-actions class="pa-4 pt-0">
<v-btn
variant="text"
size="small"
@click="$emit('view-member', member)"
>
<v-icon start size="16">mdi-account</v-icon>
View Details
</v-btn>
<v-spacer />
<v-btn
variant="text"
size="small"
:href="`mailto:${member.email}?subject=MonacoUSA Membership Dues Reminder`"
target="_blank"
v-if="member.email"
>
<v-icon start size="16">mdi-email</v-icon>
Email
</v-btn>
</v-card-actions>
</v-card>
</template>
<script setup lang="ts">
import type { Member } from '~/utils/types';
interface Props {
member: Member;
status: 'overdue' | 'upcoming';
loading?: boolean;
}
interface Emits {
(e: 'mark-paid', member: Member): void;
(e: 'view-member', member: Member): void;
}
const props = withDefaults(defineProps<Props>(), {
loading: false
});
defineEmits<Emits>();
// Computed properties
const memberInitials = computed(() => {
const firstName = props.member.first_name || '';
const lastName = props.member.last_name || '';
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase();
});
const avatarColor = computed(() => {
const colors = ['red', 'blue', 'green', 'orange', 'purple', 'teal', 'indigo', 'pink'];
const idNumber = parseInt(props.member.Id) || 0;
return colors[idNumber % colors.length];
});
const statusColor = computed(() => {
return props.status === 'overdue' ? 'error' : 'warning';
});
const statusIcon = computed(() => {
return props.status === 'overdue' ? 'mdi-alert-circle' : 'mdi-clock-alert';
});
const statusText = computed(() => {
return props.status === 'overdue' ? 'Overdue' : 'Due Soon';
});
const daysDifference = computed(() => {
if (!props.member.payment_due_date) return null;
const today = new Date();
const dueDate = new Date(props.member.payment_due_date);
const diffTime = dueDate.getTime() - today.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return diffDays;
});
// Methods
const formatDate = (dateString: string): string => {
if (!dateString) return '';
try {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
} catch {
return dateString;
}
};
</script>
<style scoped>
.dues-action-card {
border-radius: 12px !important;
transition: all 0.3s ease;
position: relative;
height: 100%;
}
.dues-action-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important;
}
.dues-action-card--overdue {
border-left: 4px solid rgb(var(--v-theme-error));
}
.dues-action-card--upcoming {
border-left: 4px solid rgb(var(--v-theme-warning));
}
.status-badge {
position: absolute;
top: 12px;
right: 12px;
z-index: 2;
}
.dues-info {
background: rgba(var(--v-theme-surface-variant), 0.1);
border-radius: 8px;
padding: 12px;
}
.contact-info {
border-radius: 6px;
padding: 8px;
background: rgba(var(--v-theme-surface-variant), 0.05);
}
.text-truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 150px;
}
/* Mobile responsive */
@media (max-width: 600px) {
.dues-action-card {
margin-bottom: 12px;
}
}
</style>

View File

@ -20,7 +20,7 @@
</div>
<!-- Action Buttons -->
<div v-if="canEdit || canDelete" class="member-action-buttons">
<div v-if="canEdit || canDelete || (!member.keycloak_id && canCreatePortalAccount)" class="member-action-buttons">
<v-btn
v-if="canEdit"
icon
@ -43,6 +43,20 @@
>
<v-icon>mdi-delete</v-icon>
</v-btn>
<!-- Create Portal Account Button (Circular) -->
<v-btn
v-if="!member.keycloak_id && canCreatePortalAccount"
icon
size="small"
variant="text"
color="primary"
:loading="creatingPortalAccount"
@click.stop="$emit('create-portal-account', member)"
:title="'Create Portal Account for ' + member.FullName"
>
<v-icon>mdi-account-plus</v-icon>
</v-btn>
</div>
<!-- Card Content -->
@ -170,20 +184,6 @@
<v-icon start size="14">mdi-account-off</v-icon>
No Portal Account
</v-chip>
<!-- Create Portal Account Button -->
<v-btn
v-if="!member.keycloak_id && canCreatePortalAccount"
color="primary"
variant="outlined"
size="small"
:loading="creatingPortalAccount"
@click.stop="$emit('create-portal-account', member)"
class="create-portal-btn"
>
<v-icon start size="14">mdi-account-plus</v-icon>
Create Portal Account
</v-btn>
</div>
</v-card-text>

View File

@ -181,6 +181,14 @@
>
Close
</v-btn>
<v-btn
color="primary"
variant="elevated"
@click="$emit('edit', member)"
>
<v-icon start>mdi-pencil</v-icon>
Edit
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
@ -197,6 +205,7 @@ interface Props {
interface Emits {
(e: 'update:model-value', value: boolean): void;
(e: 'edit', member: Member): void;
}
const props = withDefaults(defineProps<Props>(), {

View File

@ -144,6 +144,18 @@
</v-col>
</v-row>
<!-- Dues Management Section -->
<v-row class="mb-6">
<v-col cols="12">
<BoardDuesManagement
:refresh-trigger="duesRefreshTrigger"
@view-member="handleViewMember"
@view-all-members="navigateToMembers"
@member-updated="handleMemberUpdated"
/>
</v-col>
</v-row>
<!-- Recent Board Activity -->
<v-row class="mb-6">
<v-col cols="12">
@ -224,6 +236,8 @@
</template>
<script setup lang="ts">
import type { Member } from '~/utils/types';
definePageMeta({
layout: 'dashboard',
middleware: 'auth'
@ -241,6 +255,9 @@ onMounted(() => {
}
});
// Dues management state
const duesRefreshTrigger = ref(0);
// Mock data for board dashboard
const stats = ref({
totalMembers: 156,
@ -278,13 +295,32 @@ const recentActivity = ref([
}
]);
// Dues management handlers
const handleViewMember = (member: Member) => {
// Navigate to member details or open modal
console.log('View member:', member.FullName || `${member.first_name} ${member.last_name}`);
// You could implement member detail view here
navigateToMembers();
};
const handleMemberUpdated = (member: Member) => {
console.log('Member updated:', member.FullName || `${member.first_name} ${member.last_name}`);
// Trigger dues refresh to update the lists
duesRefreshTrigger.value += 1;
// You could also update stats here if needed
// stats.value = await fetchUpdatedStats();
};
// Navigation methods (placeholder implementations)
const navigateToMeetings = () => {
console.log('Navigate to meetings');
};
const navigateToMembers = () => {
console.log('Navigate to members');
// Navigate to member list page
navigateTo('/dashboard/member-list');
};
const navigateToReports = () => {

View File

@ -197,6 +197,7 @@
<ViewMemberDialog
v-model="showViewDialog"
:member="selectedMember"
@edit="editMember"
/>
<!-- Delete Confirmation Dialog -->
@ -326,7 +327,9 @@ const filteredMembers = computed(() => {
filtered = filtered.filter(member =>
member.FullName?.toLowerCase().includes(search) ||
member.email?.toLowerCase().includes(search) ||
member.phone?.includes(search)
member.phone?.includes(search) ||
member.member_id?.toLowerCase().includes(search) ||
`MUSA-${member.Id}`.toLowerCase().includes(search) // Search by generated member ID format
);
}

View File

@ -0,0 +1,52 @@
// server/api/members/[id]/mark-dues-paid.post.ts
export default defineEventHandler(async (event) => {
try {
const memberId = getRouterParam(event, 'id');
if (!memberId) {
throw createError({
statusCode: 400,
statusMessage: 'Member ID is required'
});
}
const { updateMember, getMemberById } = await import('~/server/utils/nocodb');
// Get current member data
const currentMember = await getMemberById(memberId);
if (!currentMember) {
throw createError({
statusCode: 404,
statusMessage: 'Member not found'
});
}
// Prepare update data
const today = new Date();
const updateData = {
current_year_dues_paid: 'true',
membership_date_paid: today.toISOString().split('T')[0], // YYYY-MM-DD format
payment_due_date: undefined // Clear the due date since it's now paid
};
// Update the member
const updatedMember = await updateMember(memberId, updateData);
console.log(`[API] Successfully marked dues as paid for member ${memberId}`);
return {
success: true,
data: updatedMember,
message: `Dues marked as paid for ${updatedMember.first_name} ${updatedMember.last_name}`
};
} catch (error: any) {
console.error('[API] Error marking dues as paid:', error);
throw createError({
statusCode: error.statusCode || 500,
statusMessage: error.message || 'Failed to mark dues as paid'
});
}
});

View File

@ -0,0 +1,75 @@
// server/api/members/dues-status.get.ts
export default defineEventHandler(async (event) => {
try {
const { getMembers } = await import('~/server/utils/nocodb');
// Get all members
const allMembers = await getMembers();
if (!allMembers?.list) {
return {
success: true,
data: {
overdue: [],
upcoming: []
}
};
}
const today = new Date();
const thirtyDaysFromNow = new Date();
thirtyDaysFromNow.setDate(today.getDate() + 30);
const overdueMembers: any[] = [];
const upcomingMembers: any[] = [];
for (const member of allMembers.list) {
// Skip if dues are already paid
if (member.current_year_dues_paid === 'true') {
continue;
}
// Check if member has a payment due date
if (member.payment_due_date) {
const dueDate = new Date(member.payment_due_date);
if (dueDate < today) {
// Overdue
overdueMembers.push(member);
} else if (dueDate <= thirtyDaysFromNow) {
// Due within 30 days
upcomingMembers.push(member);
}
}
}
// Sort by due date (most overdue/earliest due first)
overdueMembers.sort((a, b) => {
const dateA = new Date(a.payment_due_date);
const dateB = new Date(b.payment_due_date);
return dateA.getTime() - dateB.getTime();
});
upcomingMembers.sort((a, b) => {
const dateA = new Date(a.payment_due_date);
const dateB = new Date(b.payment_due_date);
return dateA.getTime() - dateB.getTime();
});
return {
success: true,
data: {
overdue: overdueMembers,
upcoming: upcomingMembers
}
};
} catch (error: any) {
console.error('[API] Error fetching dues status:', error);
throw createError({
statusCode: error.statusCode || 500,
statusMessage: error.message || 'Failed to fetch dues status'
});
}
});