feat: Enhanced dues overdue system with detailed time tracking
Build And Push Image / docker (push) Successful in 2m42s Details

- Enhanced update-overdue-statuses API to calculate and return specific overdue durations (years/months)
- Updated overdue-count API to provide detailed member information with overdue durations
- Enhanced DuesOverdueBanner component to display expandable list of overdue members with their specific overdue time
- Added automatic marking of members as inactive when dues are over 1 year overdue
- Improved UI to show 'Dues Overdue - X Members Affected' with detailed breakdown
- Members with overdue dues now display exact time overdue (e.g., '2 years 3 months overdue')
- Added proper TypeScript interfaces for overdue member data
- Enhanced banner shows inactive status and overdue duration for each affected member
This commit is contained in:
Matt 2025-08-10 23:42:17 +02:00
parent ff85d1c722
commit d9ef5bbdeb
4 changed files with 262 additions and 15 deletions

View File

@ -1,6 +1,6 @@
<template> <template>
<v-alert <v-alert
v-if="overdueCount > 0" v-if="overdueCount > 0 && !dismissed"
type="warning" type="warning"
variant="elevated" variant="elevated"
class="dues-overdue-banner mb-6" class="dues-overdue-banner mb-6"
@ -13,7 +13,7 @@
<template #title> <template #title>
<span class="text-h6 font-weight-bold"> <span class="text-h6 font-weight-bold">
{{ overdueCount }} Member{{ overdueCount > 1 ? 's' : '' }} with Overdue Dues Dues Overdue - {{ overdueCount }} Member{{ overdueCount > 1 ? 's' : '' }} Affected
</span> </span>
</template> </template>
@ -23,6 +23,70 @@
These accounts have been automatically marked as inactive. These accounts have been automatically marked as inactive.
</p> </p>
<!-- Detailed Overdue List -->
<v-expansion-panels
v-if="overdueMembers && overdueMembers.length > 0"
class="mb-4"
variant="accordion"
>
<v-expansion-panel
title="View Overdue Details"
:text="`Click to see all ${overdueCount} overdue members and their specific overdue durations`"
>
<template #text>
<v-list class="pa-0">
<v-list-item
v-for="member in overdueMembers"
:key="member.id"
class="overdue-member-item"
>
<template #prepend>
<v-avatar
:color="member.isInactive ? 'grey' : 'warning'"
size="32"
class="mr-3"
>
<v-icon color="white" size="16">
{{ member.isInactive ? 'mdi-account-off' : 'mdi-account-alert' }}
</v-icon>
</v-avatar>
</template>
<v-list-item-title class="font-weight-medium">
{{ member.name }}
</v-list-item-title>
<v-list-item-subtitle>
{{ member.email }}
</v-list-item-subtitle>
<template #append>
<div class="text-right">
<v-chip
:color="member.isInactive ? 'grey' : 'error'"
size="small"
variant="flat"
class="mb-1"
>
<v-icon start size="12">mdi-clock-alert</v-icon>
{{ member.overdueDuration }}
</v-chip>
<br>
<v-chip
:color="member.isInactive ? 'grey' : 'warning'"
size="x-small"
variant="tonal"
>
{{ member.isInactive ? 'Inactive' : member.status }}
</v-chip>
</div>
</template>
</v-list-item>
</v-list>
</template>
</v-expansion-panel>
</v-expansion-panels>
<div class="d-flex flex-wrap gap-2 align-center"> <div class="d-flex flex-wrap gap-2 align-center">
<v-btn <v-btn
color="warning" color="warning"
@ -73,6 +137,16 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
interface OverdueMember {
id: string;
name: string;
email: string;
status: string;
overdueDuration: string;
totalMonthsOverdue: number;
isInactive: boolean;
}
interface Props { interface Props {
overdueCount: number; overdueCount: number;
canUpdateStatuses?: boolean; canUpdateStatuses?: boolean;
@ -97,6 +171,27 @@ const emit = defineEmits<Emits>();
// State // State
const dismissed = ref(false); const dismissed = ref(false);
const updatingStatuses = ref(false); const updatingStatuses = ref(false);
const overdueMembers = ref<OverdueMember[]>([]);
// Load overdue member details
const loadOverdueDetails = async () => {
try {
const response = await $fetch<{
success: boolean;
data: {
count: number;
overdueMembers: OverdueMember[];
};
}>('/api/members/overdue-count');
if (response.success) {
overdueMembers.value = response.data.overdueMembers || [];
}
} catch (error: any) {
console.error('Error loading overdue details:', error);
overdueMembers.value = [];
}
};
// Update overdue member statuses // Update overdue member statuses
const updateOverdueStatuses = async () => { const updateOverdueStatuses = async () => {
@ -114,6 +209,9 @@ const updateOverdueStatuses = async () => {
if (response.success) { if (response.success) {
emit('statuses-updated', response.data.updatedCount); emit('statuses-updated', response.data.updatedCount);
console.log(`Updated ${response.data.updatedCount} overdue member statuses`); console.log(`Updated ${response.data.updatedCount} overdue member statuses`);
// Refresh overdue details after update
await loadOverdueDetails();
} else { } else {
throw new Error(response.message || 'Failed to update statuses'); throw new Error(response.message || 'Failed to update statuses');
} }
@ -128,12 +226,21 @@ const updateOverdueStatuses = async () => {
// Reset dismissed state when refresh trigger changes // Reset dismissed state when refresh trigger changes
watch(() => props.refreshTrigger, () => { watch(() => props.refreshTrigger, () => {
dismissed.value = false; dismissed.value = false;
loadOverdueDetails(); // Refresh data
}); });
// Watch for overdueCount changes and reset dismissed // Watch for overdueCount changes and reset dismissed
watch(() => props.overdueCount, (newCount, oldCount) => { watch(() => props.overdueCount, (newCount, oldCount) => {
if (newCount > oldCount) { if (newCount > oldCount) {
dismissed.value = false; dismissed.value = false;
loadOverdueDetails(); // Load details when count changes
}
});
// Load details on component mount
onMounted(() => {
if (props.overdueCount > 0) {
loadOverdueDetails();
} }
}); });
</script> </script>

View File

@ -572,7 +572,13 @@ const createPortalAccount = async (member: Member) => {
// Overdue dues handlers // Overdue dues handlers
const loadOverdueCount = async () => { const loadOverdueCount = async () => {
try { try {
const response = await $fetch<{ success: boolean; data: { count: number } }>('/api/members/overdue-count'); const response = await $fetch<{
success: boolean;
data: {
count: number;
overdueMembers: any[];
}
}>('/api/members/overdue-count');
if (response.success) { if (response.success) {
overdueCount.value = response.data.count; overdueCount.value = response.data.count;
} }

View File

@ -1,4 +1,35 @@
// server/api/members/overdue-count.get.ts // server/api/members/overdue-count.get.ts
// Helper function to calculate overdue duration
function calculateOverdueDuration(dueDate: Date, today: Date): {
years: number;
months: number;
totalMonths: number;
formattedDuration: string;
} {
const diffTime = today.getTime() - dueDate.getTime();
const diffMonths = Math.floor(diffTime / (1000 * 60 * 60 * 24 * 30.44)); // Average days per month
const years = Math.floor(diffMonths / 12);
const months = diffMonths % 12;
let formattedDuration = '';
if (years > 0) {
formattedDuration += `${years} year${years !== 1 ? 's' : ''}`;
if (months > 0) {
formattedDuration += ` ${months} month${months !== 1 ? 's' : ''}`;
}
} else {
formattedDuration = `${diffMonths} month${diffMonths !== 1 ? 's' : ''}`;
}
return {
years,
months,
totalMonths: diffMonths,
formattedDuration: `${formattedDuration} overdue`
};
}
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
try { try {
const { getMembers } = await import('~/server/utils/nocodb'); const { getMembers } = await import('~/server/utils/nocodb');
@ -9,16 +40,19 @@ export default defineEventHandler(async (event) => {
if (!allMembers?.list) { if (!allMembers?.list) {
return { return {
success: true, success: true,
data: { count: 0 } data: {
count: 0,
overdueMembers: []
}
}; };
} }
const today = new Date(); const today = new Date();
let severelyOverdueCount = 0; const overdueMembers: any[] = [];
for (const member of allMembers.list) { for (const member of allMembers.list) {
// Check for severely overdue members (more than 1 year past due date) // Check for severely overdue members (more than 1 year past due date)
let isSeverelyOverdue = false; let overdueDuration = null;
if (member.current_year_dues_paid === 'true' && member.membership_date_paid) { if (member.current_year_dues_paid === 'true' && member.membership_date_paid) {
// If dues are marked as paid, check if it's been more than 1 year since payment // If dues are marked as paid, check if it's been more than 1 year since payment
@ -27,7 +61,7 @@ export default defineEventHandler(async (event) => {
oneYearFromPayment.setFullYear(oneYearFromPayment.getFullYear() + 1); oneYearFromPayment.setFullYear(oneYearFromPayment.getFullYear() + 1);
if (today > oneYearFromPayment) { if (today > oneYearFromPayment) {
isSeverelyOverdue = true; overdueDuration = calculateOverdueDuration(oneYearFromPayment, today);
} }
} else if (member.current_year_dues_paid !== 'true') { } else if (member.current_year_dues_paid !== 'true') {
// If dues are not paid, check payment due date or member since date // If dues are not paid, check payment due date or member since date
@ -49,18 +83,32 @@ export default defineEventHandler(async (event) => {
oneYearOverdue.setFullYear(oneYearOverdue.getFullYear() + 1); oneYearOverdue.setFullYear(oneYearOverdue.getFullYear() + 1);
if (today > oneYearOverdue) { if (today > oneYearOverdue) {
isSeverelyOverdue = true; overdueDuration = calculateOverdueDuration(oneYearOverdue, today);
} }
} }
if (isSeverelyOverdue) { if (overdueDuration) {
severelyOverdueCount++; overdueMembers.push({
id: member.Id,
name: member.FullName || `${member.first_name} ${member.last_name}`,
email: member.email,
status: member.membership_status,
overdueDuration: overdueDuration.formattedDuration,
totalMonthsOverdue: overdueDuration.totalMonths,
isInactive: member.membership_status === 'Inactive'
});
} }
} }
// Sort by most overdue first
overdueMembers.sort((a, b) => b.totalMonthsOverdue - a.totalMonthsOverdue);
return { return {
success: true, success: true,
data: { count: severelyOverdueCount } data: {
count: overdueMembers.length,
overdueMembers: overdueMembers
}
}; };
} catch (error: any) { } catch (error: any) {

View File

@ -1,4 +1,35 @@
// server/api/members/update-overdue-statuses.post.ts // server/api/members/update-overdue-statuses.post.ts
// Helper function to calculate overdue duration
function calculateOverdueDuration(dueDate: Date, today: Date): {
years: number;
months: number;
totalMonths: number;
formattedDuration: string;
} {
const diffTime = today.getTime() - dueDate.getTime();
const diffMonths = Math.floor(diffTime / (1000 * 60 * 60 * 24 * 30.44)); // Average days per month
const years = Math.floor(diffMonths / 12);
const months = diffMonths % 12;
let formattedDuration = '';
if (years > 0) {
formattedDuration += `${years} year${years !== 1 ? 's' : ''}`;
if (months > 0) {
formattedDuration += ` ${months} month${months !== 1 ? 's' : ''}`;
}
} else {
formattedDuration = `${diffMonths} month${diffMonths !== 1 ? 's' : ''}`;
}
return {
years,
months,
totalMonths: diffMonths,
formattedDuration: `${formattedDuration} overdue`
};
}
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
try { try {
const { getMembers, updateMember } = await import('~/server/utils/nocodb'); const { getMembers, updateMember } = await import('~/server/utils/nocodb');
@ -16,15 +47,57 @@ export default defineEventHandler(async (event) => {
const today = new Date(); const today = new Date();
const membersToUpdate: any[] = []; const membersToUpdate: any[] = [];
const overdueDetails: any[] = [];
for (const member of allMembers.list) { for (const member of allMembers.list) {
// Skip if already inactive // Skip if already inactive
if (member.membership_status === 'Inactive') { if (member.membership_status === 'Inactive') {
// Still check if this inactive member is overdue to include in details
let overdueDuration = null;
if (member.current_year_dues_paid === 'true' && member.membership_date_paid) {
const lastPaidDate = new Date(member.membership_date_paid);
const oneYearFromPayment = new Date(lastPaidDate);
oneYearFromPayment.setFullYear(oneYearFromPayment.getFullYear() + 1);
if (today > oneYearFromPayment) {
overdueDuration = calculateOverdueDuration(oneYearFromPayment, today);
}
} else if (member.current_year_dues_paid !== 'true') {
let dueDate: Date;
if (member.payment_due_date) {
dueDate = new Date(member.payment_due_date);
} else if (member.member_since) {
dueDate = new Date(member.member_since);
dueDate.setFullYear(dueDate.getFullYear() + 1);
} else {
continue;
}
const oneYearOverdue = new Date(dueDate);
oneYearOverdue.setFullYear(oneYearOverdue.getFullYear() + 1);
if (today > oneYearOverdue) {
overdueDuration = calculateOverdueDuration(oneYearOverdue, today);
}
}
if (overdueDuration) {
overdueDetails.push({
id: member.Id,
name: member.FullName || `${member.first_name} ${member.last_name}`,
status: 'Inactive',
overdueDuration: overdueDuration.formattedDuration,
totalMonthsOverdue: overdueDuration.totalMonths
});
}
continue; continue;
} }
// Check if member has overdue dues (more than 1 year) // Check if member has overdue dues (more than 1 year)
let isOverdue = false; let isOverdue = false;
let overdueDuration = null;
if (member.current_year_dues_paid === 'true' && member.membership_date_paid) { if (member.current_year_dues_paid === 'true' && member.membership_date_paid) {
// If dues are marked as paid, check if it's been more than 1 year since payment // If dues are marked as paid, check if it's been more than 1 year since payment
@ -34,6 +107,7 @@ export default defineEventHandler(async (event) => {
if (today > oneYearFromPayment) { if (today > oneYearFromPayment) {
isOverdue = true; isOverdue = true;
overdueDuration = calculateOverdueDuration(oneYearFromPayment, today);
} }
} else if (member.current_year_dues_paid !== 'true') { } else if (member.current_year_dues_paid !== 'true') {
// If dues are not paid, check payment due date or member since date // If dues are not paid, check payment due date or member since date
@ -56,14 +130,25 @@ export default defineEventHandler(async (event) => {
if (today > oneYearOverdue) { if (today > oneYearOverdue) {
isOverdue = true; isOverdue = true;
overdueDuration = calculateOverdueDuration(oneYearOverdue, today);
} }
} }
if (isOverdue) { if (isOverdue && overdueDuration) {
membersToUpdate.push({ membersToUpdate.push({
id: member.Id, id: member.Id,
name: member.FullName || `${member.first_name} ${member.last_name}`, name: member.FullName || `${member.first_name} ${member.last_name}`,
currentStatus: member.membership_status currentStatus: member.membership_status,
overdueDuration: overdueDuration.formattedDuration,
totalMonthsOverdue: overdueDuration.totalMonths
});
overdueDetails.push({
id: member.Id,
name: member.FullName || `${member.first_name} ${member.last_name}`,
status: member.membership_status,
overdueDuration: overdueDuration.formattedDuration,
totalMonthsOverdue: overdueDuration.totalMonths
}); });
} }
} }
@ -78,7 +163,7 @@ export default defineEventHandler(async (event) => {
membership_status: 'Inactive' membership_status: 'Inactive'
}); });
updatedCount++; updatedCount++;
console.log(`[API] Marked member ${memberInfo.name} (${memberInfo.id}) as inactive due to overdue dues`); console.log(`[API] Marked member ${memberInfo.name} (${memberInfo.id}) as inactive - ${memberInfo.overdueDuration}`);
} catch (error: any) { } catch (error: any) {
console.error(`[API] Failed to update member ${memberInfo.name} (${memberInfo.id}):`, error); console.error(`[API] Failed to update member ${memberInfo.name} (${memberInfo.id}):`, error);
errors.push(`Failed to update ${memberInfo.name}: ${error.message}`); errors.push(`Failed to update ${memberInfo.name}: ${error.message}`);
@ -91,7 +176,8 @@ export default defineEventHandler(async (event) => {
success: true, success: true,
data: { data: {
updatedCount, updatedCount,
totalOverdue: membersToUpdate.length, totalOverdue: overdueDetails.length,
overdueDetails: overdueDetails.sort((a, b) => b.totalMonthsOverdue - a.totalMonthsOverdue), // Sort by most overdue first
errors: errors.length > 0 ? errors : undefined errors: errors.length > 0 ? errors : undefined
}, },
message: `Successfully updated ${updatedCount} overdue member${updatedCount !== 1 ? 's' : ''} to inactive status` message: `Successfully updated ${updatedCount} overdue member${updatedCount !== 1 ? 's' : ''} to inactive status`