Add comprehensive dues tracking with overdue calculations and enhanced UI
Build And Push Image / docker (push) Successful in 3m17s
Details
Build And Push Image / docker (push) Successful in 3m17s
Details
This commit is contained in:
parent
7a8c88c341
commit
abf6ade8cd
|
|
@ -51,27 +51,60 @@
|
||||||
|
|
||||||
<!-- Dues Information -->
|
<!-- Dues Information -->
|
||||||
<div class="dues-info mb-3">
|
<div class="dues-info mb-3">
|
||||||
|
<div v-if="status === 'overdue'">
|
||||||
|
<!-- Overdue Information -->
|
||||||
<div class="d-flex justify-space-between align-center mb-2">
|
<div class="d-flex justify-space-between align-center mb-2">
|
||||||
<span class="text-body-2 text-medium-emphasis">
|
<span class="text-body-2 text-medium-emphasis">
|
||||||
<v-icon size="14" class="mr-1">mdi-calendar</v-icon>
|
<v-icon size="14" class="mr-1">mdi-clock-alert</v-icon>
|
||||||
{{ status === 'overdue' ? 'Was Due' : 'Due Date' }}
|
Days Overdue
|
||||||
</span>
|
</span>
|
||||||
<span class="text-body-2 font-weight-bold" :class="status === 'overdue' ? 'text-error' : 'text-warning'">
|
<span class="text-body-2 font-weight-bold text-error">
|
||||||
{{ formatDate(member.payment_due_date) }}
|
{{ member.overdueDays || 0 }} days
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="daysDifference !== null" class="d-flex justify-space-between align-center">
|
<div v-if="member.overdueReason" class="overdue-reason">
|
||||||
|
<span class="text-caption text-error">
|
||||||
|
<v-icon size="12" class="mr-1">mdi-information</v-icon>
|
||||||
|
{{ member.overdueReason }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="member.membership_date_paid" class="d-flex justify-space-between align-center mt-2">
|
||||||
<span class="text-body-2 text-medium-emphasis">
|
<span class="text-body-2 text-medium-emphasis">
|
||||||
<v-icon size="14" class="mr-1">{{ status === 'overdue' ? 'mdi-clock-alert' : 'mdi-clock' }}</v-icon>
|
<v-icon size="14" class="mr-1">mdi-calendar-check</v-icon>
|
||||||
{{ status === 'overdue' ? 'Days Overdue' : 'Days Until Due' }}
|
Last Payment
|
||||||
</span>
|
</span>
|
||||||
<span class="text-body-2 font-weight-bold" :class="status === 'overdue' ? 'text-error' : 'text-warning'">
|
<span class="text-body-2">
|
||||||
{{ Math.abs(daysDifference) }} days
|
{{ formatDate(member.membership_date_paid) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<!-- Upcoming Information -->
|
||||||
|
<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>
|
||||||
|
Due Date
|
||||||
|
</span>
|
||||||
|
<span class="text-body-2 font-weight-bold text-warning">
|
||||||
|
{{ formatDate(member.nextDueDate || member.payment_due_date) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-space-between align-center">
|
||||||
|
<span class="text-body-2 text-medium-emphasis">
|
||||||
|
<v-icon size="14" class="mr-1">mdi-clock</v-icon>
|
||||||
|
Days Until Due
|
||||||
|
</span>
|
||||||
|
<span class="text-body-2 font-weight-bold text-warning">
|
||||||
|
{{ member.daysUntilDue || 0 }} days
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Contact Info -->
|
<!-- Contact Info -->
|
||||||
<div class="contact-info mb-3">
|
<div class="contact-info mb-3">
|
||||||
<div v-if="member.email" class="d-flex align-center mb-1">
|
<div v-if="member.email" class="d-flex align-center mb-1">
|
||||||
|
|
@ -130,8 +163,16 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Member } from '~/utils/types';
|
import type { Member } from '~/utils/types';
|
||||||
|
|
||||||
|
// Extended member type for dues management
|
||||||
|
interface DuesMember extends Member {
|
||||||
|
overdueDays?: number;
|
||||||
|
overdueReason?: string;
|
||||||
|
daysUntilDue?: number;
|
||||||
|
nextDueDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
member: Member;
|
member: DuesMember;
|
||||||
status: 'overdue' | 'upcoming';
|
status: 'overdue' | 'upcoming';
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -173,13 +173,65 @@ const snackbar = ref({
|
||||||
color: 'success'
|
color: 'success'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a member is in their grace period
|
||||||
|
* Uses the same logic as dues-status API
|
||||||
|
*/
|
||||||
|
const isInGracePeriod = computed(() => {
|
||||||
|
if (!memberData.value?.payment_due_date) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dueDate = new Date(memberData.value.payment_due_date);
|
||||||
|
const today = new Date();
|
||||||
|
return dueDate > today;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a member's last payment is over 1 year old
|
||||||
|
* Uses the same logic as dues-status API
|
||||||
|
*/
|
||||||
|
const isPaymentOverOneYear = computed(() => {
|
||||||
|
if (!memberData.value?.membership_date_paid) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const lastPaidDate = new Date(memberData.value.membership_date_paid);
|
||||||
|
const oneYearFromPayment = new Date(lastPaidDate);
|
||||||
|
oneYearFromPayment.setFullYear(oneYearFromPayment.getFullYear() + 1);
|
||||||
|
const today = new Date();
|
||||||
|
|
||||||
|
return today > oneYearFromPayment;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if dues are actually current
|
||||||
|
* Uses the same logic as dues-status API and MemberCard
|
||||||
|
*/
|
||||||
|
const isDuesActuallyCurrent = computed(() => {
|
||||||
|
if (!memberData.value) return false;
|
||||||
|
|
||||||
|
const paymentTooOld = isPaymentOverOneYear.value;
|
||||||
|
const duesCurrentlyPaid = memberData.value.current_year_dues_paid === 'true';
|
||||||
|
const gracePeriod = isInGracePeriod.value;
|
||||||
|
|
||||||
|
// Member is NOT overdue if they're in grace period OR (dues paid AND payment not too old)
|
||||||
|
const isOverdue = paymentTooOld || (!duesCurrentlyPaid && !gracePeriod);
|
||||||
|
|
||||||
|
return !isOverdue;
|
||||||
|
});
|
||||||
|
|
||||||
// Computed properties
|
// Computed properties
|
||||||
const shouldShowBanner = computed(() => {
|
const shouldShowBanner = computed(() => {
|
||||||
if (!user.value || !memberData.value) return false;
|
if (!user.value || !memberData.value) return false;
|
||||||
if (dismissed.value) return false;
|
if (dismissed.value) return false;
|
||||||
|
|
||||||
// Show banner if member exists and has unpaid dues
|
// Show banner if dues are NOT current
|
||||||
return memberData.value.current_year_dues_paid === 'false';
|
return !isDuesActuallyCurrent.value;
|
||||||
});
|
});
|
||||||
|
|
||||||
const daysRemaining = computed(() => {
|
const daysRemaining = computed(() => {
|
||||||
|
|
|
||||||
|
|
@ -261,39 +261,54 @@ const statusColor = computed(() => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Helper to check if dues are actually current (paid within last 12 months)
|
/**
|
||||||
const isDuesActuallyCurrent = computed(() => {
|
* Check if a member is in their grace period
|
||||||
if (props.member.current_year_dues_paid !== 'true') return false;
|
* Uses the same logic as dues-status API
|
||||||
|
*/
|
||||||
|
const isInGracePeriod = computed(() => {
|
||||||
|
if (!props.member.payment_due_date) return false;
|
||||||
|
|
||||||
if (!props.member.membership_date_paid) {
|
try {
|
||||||
// If marked as paid but no payment date, consider it invalid/overdue
|
const dueDate = new Date(props.member.payment_due_date);
|
||||||
|
const today = new Date();
|
||||||
|
return dueDate > today;
|
||||||
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const paymentDate = new Date(props.member.membership_date_paid);
|
|
||||||
const oneYearAgo = new Date();
|
|
||||||
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
|
|
||||||
|
|
||||||
return paymentDate > oneYearAgo;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Helper to check if member is in grace period (new members get 1 month)
|
/**
|
||||||
const isInGracePeriod = computed(() => {
|
* Check if a member's last payment is over 1 year old
|
||||||
// For existing members, check if they have member_since and it's within 1 month
|
* Uses the same logic as dues-status API
|
||||||
if (props.member.member_since) {
|
*/
|
||||||
const memberSince = new Date(props.member.member_since);
|
const isPaymentOverOneYear = computed(() => {
|
||||||
const oneMonthLater = new Date(memberSince);
|
if (!props.member.membership_date_paid) return false;
|
||||||
oneMonthLater.setMonth(oneMonthLater.getMonth() + 1);
|
|
||||||
return new Date() < oneMonthLater && props.member.current_year_dues_paid !== 'true';
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no member_since but has payment_due_date in the future, assume in grace period
|
try {
|
||||||
if (props.member.payment_due_date) {
|
const lastPaidDate = new Date(props.member.membership_date_paid);
|
||||||
const dueDate = new Date(props.member.payment_due_date);
|
const oneYearFromPayment = new Date(lastPaidDate);
|
||||||
return new Date() < dueDate && props.member.current_year_dues_paid !== 'true';
|
oneYearFromPayment.setFullYear(oneYearFromPayment.getFullYear() + 1);
|
||||||
}
|
const today = new Date();
|
||||||
|
|
||||||
|
return today > oneYearFromPayment;
|
||||||
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if dues are actually current
|
||||||
|
* Uses the same logic as dues-status API
|
||||||
|
*/
|
||||||
|
const isDuesActuallyCurrent = computed(() => {
|
||||||
|
const paymentTooOld = isPaymentOverOneYear.value;
|
||||||
|
const duesCurrentlyPaid = props.member.current_year_dues_paid === 'true';
|
||||||
|
const gracePeriod = isInGracePeriod.value;
|
||||||
|
|
||||||
|
// Member is NOT overdue if they're in grace period OR (dues paid AND payment not too old)
|
||||||
|
const isOverdue = paymentTooOld || (!duesCurrentlyPaid && !gracePeriod);
|
||||||
|
|
||||||
|
return !isOverdue;
|
||||||
});
|
});
|
||||||
|
|
||||||
const duesColor = computed(() => {
|
const duesColor = computed(() => {
|
||||||
|
|
|
||||||
|
|
@ -112,16 +112,6 @@
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
|
|
||||||
<!-- Overdue Dues Banner -->
|
|
||||||
<DuesOverdueBanner
|
|
||||||
:overdue-count="overdueCount"
|
|
||||||
:can-update-statuses="true"
|
|
||||||
:can-send-reminders="true"
|
|
||||||
:refresh-trigger="overdueRefreshTrigger"
|
|
||||||
@view-overdue="viewOverdueMembers"
|
|
||||||
@send-reminders="sendDuesReminders"
|
|
||||||
@statuses-updated="handleStatusesUpdated"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Dues Management -->
|
<!-- Dues Management -->
|
||||||
<v-row class="mb-6">
|
<v-row class="mb-6">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
<template>
|
<template>
|
||||||
<v-container>
|
<v-container>
|
||||||
|
<!-- Dues Payment Banner -->
|
||||||
|
<DuesPaymentBanner />
|
||||||
|
|
||||||
<!-- Welcome Header -->
|
<!-- Welcome Header -->
|
||||||
<v-row class="mb-6">
|
<v-row class="mb-6">
|
||||||
<v-col>
|
<v-col>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
<template>
|
<template>
|
||||||
<v-container fluid>
|
<v-container fluid>
|
||||||
|
<!-- Dues Payment Banner -->
|
||||||
|
<DuesPaymentBanner />
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<v-row class="mb-4">
|
<v-row class="mb-4">
|
||||||
<v-col>
|
<v-col>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
<template>
|
<template>
|
||||||
<v-container>
|
<v-container>
|
||||||
|
<!-- Dues Payment Banner -->
|
||||||
|
<DuesPaymentBanner />
|
||||||
|
|
||||||
<!-- Page Header -->
|
<!-- Page Header -->
|
||||||
<v-row class="mb-6">
|
<v-row class="mb-6">
|
||||||
<v-col>
|
<v-col>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
<template>
|
<template>
|
||||||
<v-container>
|
<v-container>
|
||||||
|
<!-- Dues Payment Banner -->
|
||||||
|
<DuesPaymentBanner />
|
||||||
|
|
||||||
<!-- Welcome Header -->
|
<!-- Welcome Header -->
|
||||||
<v-row class="mb-6">
|
<v-row class="mb-6">
|
||||||
<v-col>
|
<v-col>
|
||||||
|
|
|
||||||
|
|
@ -1,117 +1,248 @@
|
||||||
// server/api/members/dues-status.get.ts
|
import type { Member } from '~/utils/types';
|
||||||
export default defineEventHandler(async (event) => {
|
|
||||||
|
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 {
|
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
|
// Get all members
|
||||||
const allMembers = await getMembers();
|
const allMembers = await getMembers();
|
||||||
|
|
||||||
if (!allMembers?.list) {
|
if (!allMembers?.list?.length) {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
overdue: [],
|
overdue: [],
|
||||||
upcoming: []
|
upcoming: [],
|
||||||
|
autoUpdated: 0
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const thirtyDaysFromNow = new Date();
|
const overdueMembers: DuesMemberWithStatus[] = [];
|
||||||
thirtyDaysFromNow.setDate(today.getDate() + 30);
|
const upcomingMembers: DuesMemberWithStatus[] = [];
|
||||||
|
const membersToUpdate: Member[] = [];
|
||||||
|
|
||||||
const overdueMembers: any[] = [];
|
console.log(`[dues-status] Processing ${allMembers.list.length} members for dues status...`);
|
||||||
const upcomingMembers: any[] = [];
|
|
||||||
const severelyOverdueMembers: 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)
|
const memberName = `${member.first_name || ''} ${member.last_name || ''}`.trim() || `Member ${member.Id}`;
|
||||||
let isSeverelyOverdue = false;
|
|
||||||
|
|
||||||
if (member.current_year_dues_paid === 'true' && member.membership_date_paid) {
|
// Check member status
|
||||||
// If dues are marked as paid, check if it's been more than 1 year since payment
|
const gracePeriod = isInGracePeriod(member, today);
|
||||||
const lastPaidDate = new Date(member.membership_date_paid);
|
const paymentTooOld = isPaymentOverOneYear(member, today);
|
||||||
const oneYearFromPayment = new Date(lastPaidDate);
|
const duesCurrentlyPaid = member.current_year_dues_paid === 'true';
|
||||||
oneYearFromPayment.setFullYear(oneYearFromPayment.getFullYear() + 1);
|
|
||||||
|
|
||||||
if (today > oneYearFromPayment) {
|
// Determine if member is overdue
|
||||||
isSeverelyOverdue = true;
|
const isOverdue = paymentTooOld || (!duesCurrentlyPaid && !gracePeriod);
|
||||||
}
|
|
||||||
} 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) {
|
if (isOverdue) {
|
||||||
dueDate = new Date(member.payment_due_date);
|
// Handle overdue member
|
||||||
} else if (member.member_since) {
|
if (paymentTooOld && member.membership_status !== 'Inactive') {
|
||||||
// Fallback: 1 year from member since date
|
console.log(`[dues-status] ${memberName}: Auto-updating to Inactive (payment over 1 year old)`);
|
||||||
dueDate = new Date(member.member_since);
|
membersToUpdate.push(member);
|
||||||
dueDate.setFullYear(dueDate.getFullYear() + 1);
|
|
||||||
} else {
|
|
||||||
// Skip if we can't determine due date
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if more than 1 year overdue
|
const overdueDays = calculateOverdueDays(member, today, paymentTooOld);
|
||||||
const oneYearOverdue = new Date(dueDate);
|
const overdueReason = paymentTooOld
|
||||||
oneYearOverdue.setFullYear(oneYearOverdue.getFullYear() + 1);
|
? `Payment from ${member.membership_date_paid} is over 1 year old`
|
||||||
|
: 'Current year dues not paid';
|
||||||
|
|
||||||
if (today > oneYearOverdue) {
|
overdueMembers.push({
|
||||||
isSeverelyOverdue = true;
|
...member,
|
||||||
}
|
overdueDays,
|
||||||
}
|
overdueReason
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
if (dueDate < today) {
|
|
||||||
// Overdue (but not severely 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) => {
|
} else if (duesCurrentlyPaid || gracePeriod) {
|
||||||
const dateA = new Date(a.payment_due_date);
|
// Check if member has upcoming dues
|
||||||
const dateB = new Date(b.payment_due_date);
|
const upcomingInfo = calculateDaysUntilDue(member, today);
|
||||||
return dateA.getTime() - dateB.getTime();
|
|
||||||
|
if (upcomingInfo) {
|
||||||
|
upcomingMembers.push({
|
||||||
|
...member,
|
||||||
|
...upcomingInfo
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
overdue: overdueMembers,
|
overdue: overdueMembers,
|
||||||
upcoming: upcomingMembers
|
upcoming: upcomingMembers,
|
||||||
|
autoUpdated: autoUpdatedCount
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('[API] Error fetching dues status:', error);
|
console.error('[dues-status] ✗ Error processing dues status:', error);
|
||||||
|
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: error.statusCode || 500,
|
statusCode: error.statusCode || 500,
|
||||||
statusMessage: error.message || 'Failed to fetch dues status'
|
statusMessage: error.message || 'Failed to process dues status'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@
|
||||||
color: #333;
|
color: #333;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
|
background: linear-gradient(135deg, #a31515 0%, #8b1212 50%, #a31515 100%);
|
||||||
background-image: url('{{baseUrl}}/monaco_high_res.jpg');
|
background-image: url('{{baseUrl}}/monaco_high_res.jpg');
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
background-position: center;
|
background-position: center;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,243 @@
|
||||||
|
import type { Member, DuesCalculationUtils } from '~/utils/types';
|
||||||
|
|
||||||
|
// Constants for date calculations
|
||||||
|
export const MILLISECONDS_PER_DAY = 1000 * 60 * 60 * 24;
|
||||||
|
export const DAYS_IN_YEAR = 365;
|
||||||
|
export const UPCOMING_THRESHOLD_DAYS = 30;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate days between two dates
|
||||||
|
*/
|
||||||
|
export 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
|
||||||
|
* Used consistently across all dues components
|
||||||
|
*/
|
||||||
|
export function isInGracePeriod(member: Member): boolean {
|
||||||
|
if (!member.payment_due_date) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dueDate = new Date(member.payment_due_date);
|
||||||
|
const today = new Date();
|
||||||
|
return dueDate > today;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a member's last payment is over 1 year old
|
||||||
|
* Used consistently across all dues components
|
||||||
|
*/
|
||||||
|
export function isPaymentOverOneYear(member: Member): 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);
|
||||||
|
const today = new Date();
|
||||||
|
|
||||||
|
return today > oneYearFromPayment;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if dues are actually current
|
||||||
|
* Used consistently across all dues components
|
||||||
|
* This is the main logic used throughout the system
|
||||||
|
*/
|
||||||
|
export function isDuesActuallyCurrent(member: Member): boolean {
|
||||||
|
const paymentTooOld = isPaymentOverOneYear(member);
|
||||||
|
const duesCurrentlyPaid = member.current_year_dues_paid === 'true';
|
||||||
|
const gracePeriod = isInGracePeriod(member);
|
||||||
|
|
||||||
|
// Member is NOT overdue if they're in grace period OR (dues paid AND payment not too old)
|
||||||
|
const isOverdue = paymentTooOld || (!duesCurrentlyPaid && !gracePeriod);
|
||||||
|
|
||||||
|
return !isOverdue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate overdue days for a member
|
||||||
|
*/
|
||||||
|
export function calculateOverdueDays(member: Member): number {
|
||||||
|
const today = new Date();
|
||||||
|
const paymentTooOld = isPaymentOverOneYear(member);
|
||||||
|
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
export function calculateDaysUntilDue(member: Member): { daysUntilDue: number; nextDueDate: string } | null {
|
||||||
|
const today = new Date();
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the next dues date for a member
|
||||||
|
* Used for display purposes
|
||||||
|
*/
|
||||||
|
export function getNextDuesDate(member: Member): string {
|
||||||
|
// If dues are paid, calculate 1 year from payment date
|
||||||
|
if (member.current_year_dues_paid === 'true' && member.membership_date_paid) {
|
||||||
|
try {
|
||||||
|
const lastPaidDate = new Date(member.membership_date_paid);
|
||||||
|
const nextDue = new Date(lastPaidDate);
|
||||||
|
nextDue.setFullYear(nextDue.getFullYear() + 1);
|
||||||
|
return nextDue.toISOString().split('T')[0];
|
||||||
|
} catch {
|
||||||
|
// Fall through
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not paid but has a due date, use that
|
||||||
|
if (member.payment_due_date) {
|
||||||
|
return member.payment_due_date;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: 1 year from member since date
|
||||||
|
if (member.member_since) {
|
||||||
|
try {
|
||||||
|
const memberSince = new Date(member.member_since);
|
||||||
|
const nextDue = new Date(memberSince);
|
||||||
|
nextDue.setFullYear(nextDue.getFullYear() + 1);
|
||||||
|
return nextDue.toISOString().split('T')[0];
|
||||||
|
} catch {
|
||||||
|
// Ignore invalid dates
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get days remaining until due date
|
||||||
|
*/
|
||||||
|
export function getDaysRemaining(member: Member): number {
|
||||||
|
if (!member.payment_due_date) return 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dueDate = new Date(member.payment_due_date);
|
||||||
|
const today = new Date();
|
||||||
|
const diffTime = dueDate.getTime() - today.getTime();
|
||||||
|
const diffDays = Math.ceil(diffTime / MILLISECONDS_PER_DAY);
|
||||||
|
|
||||||
|
return Math.max(0, diffDays);
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a human-readable payment message for a member
|
||||||
|
*/
|
||||||
|
export function getPaymentMessage(member: Member, membershipFee: number = 50): string {
|
||||||
|
const daysRemaining = getDaysRemaining(member);
|
||||||
|
|
||||||
|
if (daysRemaining > 30) {
|
||||||
|
return `Your annual membership dues of €${membershipFee} are due in ${daysRemaining} days.`;
|
||||||
|
} else if (daysRemaining > 0) {
|
||||||
|
return `Your annual membership dues of €${membershipFee} are due in ${daysRemaining} days. Please pay soon to avoid account suspension.`;
|
||||||
|
} else {
|
||||||
|
return `Your annual membership dues of €${membershipFee} are overdue. Your account may be suspended soon.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export all utilities as a single object
|
||||||
|
* This matches the DuesCalculationUtils interface
|
||||||
|
*/
|
||||||
|
export const duesCalculationUtils: DuesCalculationUtils = {
|
||||||
|
isInGracePeriod,
|
||||||
|
isPaymentOverOneYear,
|
||||||
|
isDuesActuallyCurrent,
|
||||||
|
calculateOverdueDays,
|
||||||
|
calculateDaysUntilDue
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default export for easy importing
|
||||||
|
*/
|
||||||
|
export default {
|
||||||
|
// Core logic functions
|
||||||
|
isInGracePeriod,
|
||||||
|
isPaymentOverOneYear,
|
||||||
|
isDuesActuallyCurrent,
|
||||||
|
calculateOverdueDays,
|
||||||
|
calculateDaysUntilDue,
|
||||||
|
|
||||||
|
// Utility functions
|
||||||
|
calculateDaysDifference,
|
||||||
|
getNextDuesDate,
|
||||||
|
getDaysRemaining,
|
||||||
|
getPaymentMessage,
|
||||||
|
|
||||||
|
// Constants
|
||||||
|
MILLISECONDS_PER_DAY,
|
||||||
|
DAYS_IN_YEAR,
|
||||||
|
UPCOMING_THRESHOLD_DAYS
|
||||||
|
};
|
||||||
|
|
@ -389,3 +389,46 @@ export interface AdminDashboardStats {
|
||||||
[roleName: string]: number;
|
[roleName: string]: number;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dues Management System Types
|
||||||
|
export interface DuesMemberWithStatus extends Member {
|
||||||
|
overdueDays?: number;
|
||||||
|
overdueReason?: string;
|
||||||
|
daysUntilDue?: number;
|
||||||
|
nextDueDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DuesStatusResponse {
|
||||||
|
success: boolean;
|
||||||
|
data: {
|
||||||
|
overdue: DuesMemberWithStatus[];
|
||||||
|
upcoming: DuesMemberWithStatus[];
|
||||||
|
autoUpdated: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DuesOverdueCountResponse {
|
||||||
|
success: boolean;
|
||||||
|
data: {
|
||||||
|
count: number;
|
||||||
|
overdueMembers: DuesMemberWithStatus[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DuesPaymentInfo {
|
||||||
|
membershipFee: number;
|
||||||
|
iban: string;
|
||||||
|
accountHolder: string;
|
||||||
|
paymentReference: string;
|
||||||
|
daysRemaining: number;
|
||||||
|
paymentMessage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared dues calculation utilities (used across components)
|
||||||
|
export interface DuesCalculationUtils {
|
||||||
|
isInGracePeriod: (member: Member) => boolean;
|
||||||
|
isPaymentOverOneYear: (member: Member) => boolean;
|
||||||
|
isDuesActuallyCurrent: (member: Member) => boolean;
|
||||||
|
calculateOverdueDays: (member: Member) => number;
|
||||||
|
calculateDaysUntilDue: (member: Member) => { daysUntilDue: number; nextDueDate: string } | null;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue