Implement grace period system for member dues management
Build And Push Image / docker (push) Successful in 2m55s
Details
Build And Push Image / docker (push) Successful in 2m55s
Details
- Add grace period logic for new members (1 month from join date) - Enhance dues status computation with actual payment date validation - Update member card to show grace period status with warning indicators - Remove overdue dues banner from member list - Add payment due date calculation in registration process - Update email templates for improved member communication
This commit is contained in:
parent
d9ef5bbdeb
commit
7a8c88c341
|
|
@ -261,27 +261,81 @@ const statusColor = computed(() => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Helper to check if dues are actually current (paid within last 12 months)
|
||||||
|
const isDuesActuallyCurrent = computed(() => {
|
||||||
|
if (props.member.current_year_dues_paid !== 'true') return false;
|
||||||
|
|
||||||
|
if (!props.member.membership_date_paid) {
|
||||||
|
// If marked as paid but no payment date, consider it invalid/overdue
|
||||||
|
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(() => {
|
||||||
|
// For existing members, check if they have member_since and it's within 1 month
|
||||||
|
if (props.member.member_since) {
|
||||||
|
const memberSince = new Date(props.member.member_since);
|
||||||
|
const oneMonthLater = new Date(memberSince);
|
||||||
|
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
|
||||||
|
if (props.member.payment_due_date) {
|
||||||
|
const dueDate = new Date(props.member.payment_due_date);
|
||||||
|
return new Date() < dueDate && props.member.current_year_dues_paid !== 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
const duesColor = computed(() => {
|
const duesColor = computed(() => {
|
||||||
return props.member.current_year_dues_paid === 'true' ? 'success' : 'error';
|
if (isDuesActuallyCurrent.value) return 'success';
|
||||||
|
if (isInGracePeriod.value) return 'warning';
|
||||||
|
return 'error';
|
||||||
});
|
});
|
||||||
|
|
||||||
const duesVariant = computed(() => {
|
const duesVariant = computed(() => {
|
||||||
return props.member.current_year_dues_paid === 'true' ? 'tonal' : 'flat';
|
if (isDuesActuallyCurrent.value) return 'tonal';
|
||||||
|
if (isInGracePeriod.value) return 'tonal';
|
||||||
|
return 'flat';
|
||||||
});
|
});
|
||||||
|
|
||||||
const duesIcon = computed(() => {
|
const duesIcon = computed(() => {
|
||||||
return props.member.current_year_dues_paid === 'true' ? 'mdi-check-circle' : 'mdi-alert-circle';
|
if (isDuesActuallyCurrent.value) return 'mdi-check-circle';
|
||||||
|
if (isInGracePeriod.value) return 'mdi-clock-alert';
|
||||||
|
return 'mdi-alert-circle';
|
||||||
});
|
});
|
||||||
|
|
||||||
const duesText = computed(() => {
|
const duesText = computed(() => {
|
||||||
return props.member.current_year_dues_paid === 'true' ? 'Dues Paid' : 'Dues Outstanding';
|
if (isDuesActuallyCurrent.value) return 'Dues Paid';
|
||||||
|
if (isInGracePeriod.value) return 'Grace Period';
|
||||||
|
return 'Dues Outstanding';
|
||||||
});
|
});
|
||||||
|
|
||||||
const isOverdue = computed(() => {
|
const isOverdue = computed(() => {
|
||||||
if (!props.member.payment_due_date) return false;
|
// If dues are current, not overdue
|
||||||
|
if (isDuesActuallyCurrent.value) return false;
|
||||||
|
|
||||||
|
// If in grace period, not yet overdue
|
||||||
|
if (isInGracePeriod.value) return false;
|
||||||
|
|
||||||
|
// Check if payment_due_date has passed
|
||||||
|
if (props.member.payment_due_date) {
|
||||||
const dueDate = new Date(props.member.payment_due_date);
|
const dueDate = new Date(props.member.payment_due_date);
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
return dueDate < today && props.member.current_year_dues_paid !== 'true';
|
return dueDate < today;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no due date but not paid and not in grace period, consider overdue
|
||||||
|
return props.member.current_year_dues_paid !== 'true';
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculate next dues date (1 year from when they last paid)
|
// Calculate next dues date (1 year from when they last paid)
|
||||||
|
|
|
||||||
|
|
@ -13,16 +13,6 @@
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
|
|
||||||
<!-- Overdue Dues Banner -->
|
|
||||||
<DuesOverdueBanner
|
|
||||||
:overdue-count="overdueCount"
|
|
||||||
:can-update-statuses="isAdmin"
|
|
||||||
:can-send-reminders="isAdmin || isBoard"
|
|
||||||
:refresh-trigger="overdueRefreshTrigger"
|
|
||||||
@view-overdue="viewOverdueMembers"
|
|
||||||
@send-reminders="sendDuesReminders"
|
|
||||||
@statuses-updated="handleStatusesUpdated"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Search and Filter Controls -->
|
<!-- Search and Filter Controls -->
|
||||||
<v-row class="mb-4">
|
<v-row class="mb-4">
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
import { getMembers, updateMember } from '~/server/utils/nocodb';
|
||||||
|
import { findMembersWithoutMemberID, generateMemberID, extractMemberIDNumber } from '~/server/utils/member-id';
|
||||||
|
import type { Member } from '~/utils/types';
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
console.log('[api/admin/assign-member-ids.post] =========================');
|
||||||
|
console.log('[api/admin/assign-member-ids.post] POST /api/admin/assign-member-ids - Assign member IDs to existing members');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if user is admin
|
||||||
|
const sessionManager = createSessionManager();
|
||||||
|
const cookieHeader = getHeader(event, 'cookie');
|
||||||
|
const session = sessionManager.getSession(cookieHeader);
|
||||||
|
|
||||||
|
if (!session || session.user.tier !== 'admin') {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 403,
|
||||||
|
statusMessage: 'Admin access required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all members and find those without member IDs
|
||||||
|
const allMembersResult = await getMembers();
|
||||||
|
const allMembers = allMembersResult.list || [];
|
||||||
|
console.log(`[api/admin/assign-member-ids.post] Found ${allMembers.length} total members`);
|
||||||
|
|
||||||
|
// Find members without member_id
|
||||||
|
const membersWithoutIds = await findMembersWithoutMemberID();
|
||||||
|
console.log(`[api/admin/assign-member-ids.post] Found ${membersWithoutIds.length} members without IDs`);
|
||||||
|
|
||||||
|
if (membersWithoutIds.length === 0) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'All members already have member IDs assigned',
|
||||||
|
data: {
|
||||||
|
totalMembers: allMembers.length,
|
||||||
|
membersUpdated: 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the highest existing member ID number to continue sequence
|
||||||
|
const existingMemberIds = allMembers
|
||||||
|
.filter((member: Member) => member.member_id)
|
||||||
|
.map((member: Member) => extractMemberIDNumber(member.member_id!))
|
||||||
|
.filter(num => num !== null) as number[];
|
||||||
|
|
||||||
|
// Start from next available number, or 1 if no existing IDs
|
||||||
|
let nextIdNumber = existingMemberIds.length > 0 ? Math.max(...existingMemberIds) + 1 : 1;
|
||||||
|
console.log(`[api/admin/assign-member-ids.post] Starting with member ID number: ${nextIdNumber}`);
|
||||||
|
|
||||||
|
// Assign IDs to members without them
|
||||||
|
const updatedMembers = [];
|
||||||
|
|
||||||
|
for (const member of membersWithoutIds) {
|
||||||
|
// Use 4-digit padding for member IDs (MUSA-0001, MUSA-0002, etc.)
|
||||||
|
const memberId = `MUSA-${nextIdNumber.toString().padStart(4, '0')}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateMember(member.Id, {
|
||||||
|
member_id: memberId
|
||||||
|
});
|
||||||
|
|
||||||
|
updatedMembers.push({
|
||||||
|
id: member.Id,
|
||||||
|
name: `${member.first_name || ''} ${member.last_name || ''}`.trim() || 'Unknown',
|
||||||
|
email: member.email || 'No email',
|
||||||
|
memberId
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[api/admin/assign-member-ids.post] Assigned ${memberId} to ${member.first_name} ${member.last_name}`);
|
||||||
|
nextIdNumber++;
|
||||||
|
|
||||||
|
} catch (updateError: any) {
|
||||||
|
console.error(`[api/admin/assign-member-ids.post] Failed to assign ID to member ${member.Id}:`, updateError);
|
||||||
|
// Continue with next member rather than failing the entire operation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[api/admin/assign-member-ids.post] ✅ Successfully assigned IDs to ${updatedMembers.length} members`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Successfully assigned member IDs to ${updatedMembers.length} members`,
|
||||||
|
data: {
|
||||||
|
totalMembers: allMembers.length,
|
||||||
|
membersUpdated: updatedMembers.length,
|
||||||
|
updatedMembers: updatedMembers.slice(0, 10), // Return first 10 for display
|
||||||
|
startingId: updatedMembers.length > 0 ? `MUSA-${(nextIdNumber - updatedMembers.length).toString().padStart(4, '0')}` : null,
|
||||||
|
endingId: updatedMembers.length > 0 ? updatedMembers[updatedMembers.length - 1].memberId : null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[api/admin/assign-member-ids.post] ❌ Failed to assign member IDs:', error);
|
||||||
|
throw createError({
|
||||||
|
statusCode: error.statusCode || 500,
|
||||||
|
statusMessage: error.statusMessage || 'Failed to assign member IDs'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -94,6 +94,9 @@ export default defineEventHandler(async (event) => {
|
||||||
|
|
||||||
// 5. Create Keycloak user with role-based registration
|
// 5. Create Keycloak user with role-based registration
|
||||||
console.log('[api/registration.post] Creating Keycloak user with role-based system...');
|
console.log('[api/registration.post] Creating Keycloak user with role-based system...');
|
||||||
|
const paymentDueDate = new Date();
|
||||||
|
paymentDueDate.setMonth(paymentDueDate.getMonth() + 1); // 1 month from registration
|
||||||
|
|
||||||
const membershipData = {
|
const membershipData = {
|
||||||
membershipStatus: 'Active',
|
membershipStatus: 'Active',
|
||||||
duesStatus: 'unpaid' as const,
|
duesStatus: 'unpaid' as const,
|
||||||
|
|
@ -101,7 +104,7 @@ export default defineEventHandler(async (event) => {
|
||||||
phone: body.phone,
|
phone: body.phone,
|
||||||
address: body.address,
|
address: body.address,
|
||||||
registrationDate: new Date().toISOString(),
|
registrationDate: new Date().toISOString(),
|
||||||
paymentDueDate: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(),
|
paymentDueDate: paymentDueDate.toISOString(),
|
||||||
membershipTier: 'user' as const
|
membershipTier: 'user' as const
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -129,7 +132,7 @@ export default defineEventHandler(async (event) => {
|
||||||
registration_date: new Date().toISOString(),
|
registration_date: new Date().toISOString(),
|
||||||
member_since: new Date().getFullYear().toString(),
|
member_since: new Date().getFullYear().toString(),
|
||||||
membership_date_paid: '',
|
membership_date_paid: '',
|
||||||
payment_due_date: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString() // 3 months from now
|
payment_due_date: paymentDueDate.toISOString() // 1 month from registration
|
||||||
};
|
};
|
||||||
|
|
||||||
const member = await nocodb.create('members', memberData);
|
const member = await nocodb.create('members', memberData);
|
||||||
|
|
|
||||||
|
|
@ -9,17 +9,25 @@
|
||||||
font-family: Arial, sans-serif;
|
font-family: Arial, sans-serif;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: #333;
|
color: #333;
|
||||||
max-width: 600px;
|
margin: 0;
|
||||||
margin: 0 auto;
|
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
background-color: #f4f4f4;
|
background-image: url('{{baseUrl}}/monaco_high_res.jpg');
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-attachment: fixed;
|
||||||
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.email-container {
|
.email-container {
|
||||||
background: white;
|
background: rgba(255, 255, 255, 0.95);
|
||||||
padding: 30px;
|
backdrop-filter: blur(10px);
|
||||||
|
padding: 40px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 8px 25px rgba(163, 21, 21, 0.15);
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 20px auto;
|
||||||
|
border: 1px solid rgba(163, 21, 21, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
|
|
|
||||||
|
|
@ -9,17 +9,25 @@
|
||||||
font-family: Arial, sans-serif;
|
font-family: Arial, sans-serif;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: #333;
|
color: #333;
|
||||||
max-width: 600px;
|
margin: 0;
|
||||||
margin: 0 auto;
|
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
background-color: #f4f4f4;
|
background-image: url('{{baseUrl}}/monaco_high_res.jpg');
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-attachment: fixed;
|
||||||
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.email-container {
|
.email-container {
|
||||||
background: white;
|
background: rgba(255, 255, 255, 0.95);
|
||||||
padding: 30px;
|
backdrop-filter: blur(10px);
|
||||||
|
padding: 40px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 8px 25px rgba(163, 21, 21, 0.15);
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 20px auto;
|
||||||
|
border: 1px solid rgba(163, 21, 21, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
|
|
|
||||||
|
|
@ -9,17 +9,25 @@
|
||||||
font-family: Arial, sans-serif;
|
font-family: Arial, sans-serif;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: #333;
|
color: #333;
|
||||||
max-width: 600px;
|
margin: 0;
|
||||||
margin: 0 auto;
|
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
background-color: #f4f4f4;
|
background-image: url('{{baseUrl}}/monaco_high_res.jpg');
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-attachment: fixed;
|
||||||
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.email-container {
|
.email-container {
|
||||||
background: white;
|
background: rgba(255, 255, 255, 0.95);
|
||||||
padding: 30px;
|
backdrop-filter: blur(10px);
|
||||||
|
padding: 40px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 8px 25px rgba(163, 21, 21, 0.15);
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 20px auto;
|
||||||
|
border: 1px solid rgba(163, 21, 21, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
|
|
|
||||||
|
|
@ -9,17 +9,25 @@
|
||||||
font-family: Arial, sans-serif;
|
font-family: Arial, sans-serif;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: #333;
|
color: #333;
|
||||||
max-width: 600px;
|
margin: 0;
|
||||||
margin: 0 auto;
|
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
background-color: #f4f4f4;
|
background-image: url('{{baseUrl}}/monaco_high_res.jpg');
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-attachment: fixed;
|
||||||
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.email-container {
|
.email-container {
|
||||||
background: white;
|
background: rgba(255, 255, 255, 0.95);
|
||||||
padding: 30px;
|
backdrop-filter: blur(10px);
|
||||||
|
padding: 40px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 8px 25px rgba(163, 21, 21, 0.15);
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 20px auto;
|
||||||
|
border: 1px solid rgba(163, 21, 21, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue