Add comprehensive dues tracking with overdue calculations and enhanced UI
All checks were successful
Build And Push Image / docker (push) Successful in 3m17s

This commit is contained in:
2025-08-11 15:29:42 +02:00
parent 7a8c88c341
commit abf6ade8cd
12 changed files with 658 additions and 130 deletions

View File

@@ -1,117 +1,248 @@
// server/api/members/dues-status.get.ts
export default defineEventHandler(async (event) => {
import type { Member } from '~/utils/types';
interface DuesMemberWithStatus extends Member {
overdueDays?: number;
overdueReason?: string;
daysUntilDue?: number;
nextDueDate?: string;
}
interface DuesStatusResponse {
success: boolean;
data: {
overdue: DuesMemberWithStatus[];
upcoming: DuesMemberWithStatus[];
autoUpdated: number;
};
}
// Constants for date calculations
const MILLISECONDS_PER_DAY = 1000 * 60 * 60 * 24;
const DAYS_IN_YEAR = 365;
const UPCOMING_THRESHOLD_DAYS = 30;
/**
* Calculate days between two dates
*/
function calculateDaysDifference(date1: Date, date2: Date): number {
return Math.floor((date1.getTime() - date2.getTime()) / MILLISECONDS_PER_DAY);
}
/**
* Check if a member is in their grace period
*/
function isInGracePeriod(member: Member, today: Date): boolean {
if (!member.payment_due_date) return false;
try {
const { getMembers } = await import('~/server/utils/nocodb');
const dueDate = new Date(member.payment_due_date);
return dueDate > today;
} catch {
return false;
}
}
/**
* Check if a member's last payment is over 1 year old
*/
function isPaymentOverOneYear(member: Member, today: Date): boolean {
if (!member.membership_date_paid) return false;
try {
const lastPaidDate = new Date(member.membership_date_paid);
const oneYearFromPayment = new Date(lastPaidDate);
oneYearFromPayment.setFullYear(oneYearFromPayment.getFullYear() + 1);
return today > oneYearFromPayment;
} catch {
return false;
}
}
/**
* Calculate overdue days for a member
*/
function calculateOverdueDays(member: Member, today: Date, paymentTooOld: boolean): number {
if (paymentTooOld && member.membership_date_paid) {
try {
const lastPaidDate = new Date(member.membership_date_paid);
return calculateDaysDifference(today, lastPaidDate) - DAYS_IN_YEAR;
} catch {
return 0;
}
}
if (member.payment_due_date) {
try {
const dueDate = new Date(member.payment_due_date);
return calculateDaysDifference(today, dueDate);
} catch {
return 0;
}
}
return 0;
}
/**
* Calculate days until due for upcoming members
*/
function calculateDaysUntilDue(member: Member, today: Date): { daysUntilDue: number; nextDueDate: string } | null {
// First try membership_date_paid (existing members)
if (member.membership_date_paid) {
try {
const lastPaidDate = new Date(member.membership_date_paid);
const nextDueDate = new Date(lastPaidDate);
nextDueDate.setFullYear(nextDueDate.getFullYear() + 1);
const daysUntilDue = calculateDaysDifference(nextDueDate, today);
if (daysUntilDue <= UPCOMING_THRESHOLD_DAYS && daysUntilDue > 0) {
return {
daysUntilDue,
nextDueDate: nextDueDate.toISOString().split('T')[0]
};
}
} catch {
// Fall through to payment_due_date check
}
}
// Try payment_due_date (new members in grace period)
if (member.payment_due_date) {
try {
const dueDate = new Date(member.payment_due_date);
const daysUntilDue = calculateDaysDifference(dueDate, today);
if (daysUntilDue <= UPCOMING_THRESHOLD_DAYS && daysUntilDue > 0) {
return {
daysUntilDue,
nextDueDate: member.payment_due_date
};
}
} catch {
// Ignore invalid dates
}
}
return null;
}
export default defineEventHandler(async (event): Promise<DuesStatusResponse> => {
try {
const { getMembers, updateMember } = await import('~/server/utils/nocodb');
// Get all members
const allMembers = await getMembers();
if (!allMembers?.list) {
if (!allMembers?.list?.length) {
return {
success: true,
data: {
overdue: [],
upcoming: []
upcoming: [],
autoUpdated: 0
}
};
}
const today = new Date();
const thirtyDaysFromNow = new Date();
thirtyDaysFromNow.setDate(today.getDate() + 30);
const overdueMembers: DuesMemberWithStatus[] = [];
const upcomingMembers: DuesMemberWithStatus[] = [];
const membersToUpdate: Member[] = [];
const overdueMembers: any[] = [];
const upcomingMembers: any[] = [];
const severelyOverdueMembers: any[] = [];
console.log(`[dues-status] Processing ${allMembers.list.length} members for dues status...`);
for (const member of allMembers.list) {
// Check for severely overdue members (more than 1 year past due date)
let isSeverelyOverdue = false;
const memberName = `${member.first_name || ''} ${member.last_name || ''}`.trim() || `Member ${member.Id}`;
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
const lastPaidDate = new Date(member.membership_date_paid);
const oneYearFromPayment = new Date(lastPaidDate);
oneYearFromPayment.setFullYear(oneYearFromPayment.getFullYear() + 1);
if (today > oneYearFromPayment) {
isSeverelyOverdue = true;
}
} else if (member.current_year_dues_paid !== 'true') {
// If dues are not paid, check payment due date or member since date
let dueDate: Date;
if (member.payment_due_date) {
dueDate = new Date(member.payment_due_date);
} else if (member.member_since) {
// Fallback: 1 year from member since date
dueDate = new Date(member.member_since);
dueDate.setFullYear(dueDate.getFullYear() + 1);
} else {
// Skip if we can't determine due date
continue;
// Check member status
const gracePeriod = isInGracePeriod(member, today);
const paymentTooOld = isPaymentOverOneYear(member, today);
const duesCurrentlyPaid = member.current_year_dues_paid === 'true';
// Determine if member is overdue
const isOverdue = paymentTooOld || (!duesCurrentlyPaid && !gracePeriod);
if (isOverdue) {
// Handle overdue member
if (paymentTooOld && member.membership_status !== 'Inactive') {
console.log(`[dues-status] ${memberName}: Auto-updating to Inactive (payment over 1 year old)`);
membersToUpdate.push(member);
}
// Check if more than 1 year overdue
const oneYearOverdue = new Date(dueDate);
oneYearOverdue.setFullYear(oneYearOverdue.getFullYear() + 1);
const overdueDays = calculateOverdueDays(member, today, paymentTooOld);
const overdueReason = paymentTooOld
? `Payment from ${member.membership_date_paid} is over 1 year old`
: 'Current year dues not paid';
if (today > oneYearOverdue) {
isSeverelyOverdue = true;
}
}
if (isSeverelyOverdue) {
severelyOverdueMembers.push(member);
continue; // Don't add to other lists
}
// Skip if dues are already paid (and not severely overdue)
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);
overdueMembers.push({
...member,
overdueDays,
overdueReason
});
if (dueDate < today) {
// Overdue (but not severely overdue)
overdueMembers.push(member);
} else if (dueDate <= thirtyDaysFromNow) {
// Due within 30 days
upcomingMembers.push(member);
} else if (duesCurrentlyPaid || gracePeriod) {
// Check if member has upcoming dues
const upcomingInfo = calculateDaysUntilDue(member, today);
if (upcomingInfo) {
upcomingMembers.push({
...member,
...upcomingInfo
});
}
}
}
// 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();
});
// Auto-update members who need status changes
let autoUpdatedCount = 0;
if (membersToUpdate.length > 0) {
console.log(`[dues-status] Auto-updating ${membersToUpdate.length} members to Inactive status...`);
for (const memberUpdate of membersToUpdate) {
try {
await updateMember(memberUpdate.Id, {
membership_status: 'Inactive',
current_year_dues_paid: 'false'
});
// Update the overdue member in our list to reflect the status change
const overdueIndex = overdueMembers.findIndex(m => m.Id === memberUpdate.Id);
if (overdueIndex !== -1) {
overdueMembers[overdueIndex].membership_status = 'Inactive';
overdueMembers[overdueIndex].current_year_dues_paid = 'false';
}
autoUpdatedCount++;
console.log(`[dues-status] ✓ Updated ${memberUpdate.first_name} ${memberUpdate.last_name} to Inactive`);
} catch (error: any) {
console.error(`[dues-status] ✗ Failed to update member ${memberUpdate.Id}:`, error.message);
}
}
}
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();
});
// Sort results
overdueMembers.sort((a, b) => (b.overdueDays || 0) - (a.overdueDays || 0));
upcomingMembers.sort((a, b) => (a.daysUntilDue || 999) - (b.daysUntilDue || 999));
console.log(`[dues-status] ✓ Results: ${overdueMembers.length} overdue, ${upcomingMembers.length} upcoming, ${autoUpdatedCount} auto-updated`);
return {
success: true,
data: {
overdue: overdueMembers,
upcoming: upcomingMembers
upcoming: upcomingMembers,
autoUpdated: autoUpdatedCount
}
};
} catch (error: any) {
console.error('[API] Error fetching dues status:', error);
console.error('[dues-status] ✗ Error processing dues status:', error);
throw createError({
statusCode: error.statusCode || 500,
statusMessage: error.message || 'Failed to fetch dues status'
statusMessage: error.message || 'Failed to process dues status'
});
}
});

View File

@@ -11,6 +11,7 @@
color: #333;
margin: 0;
padding: 20px;
background: linear-gradient(135deg, #a31515 0%, #8b1212 50%, #a31515 100%);
background-image: url('{{baseUrl}}/monaco_high_res.jpg');
background-size: cover;
background-position: center;