diff --git a/.claude/settings.local.json b/.claude/settings.local.json index c82c334..17a7edd 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -81,7 +81,19 @@ "Read(/Z:\\Repos\\monacousa-portal\\layouts/**)", "Read(/Z:\\Repos\\monacousa-portal\\layouts/**)", "Read(/Z:\\Repos\\monacousa-portal\\layouts/**)", - "Read(/Z:\\Repos\\monacousa-portal\\utils/**)" + "Read(/Z:\\Repos\\monacousa-portal\\utils/**)", + "Read(/Z:\\Repos\\monacousa-portal\\server\\api\\members\\[id]/**)", + "Read(/Z:\\Repos\\monacousa-portal\\components/**)", + "Read(/Z:\\Repos\\monacousa-portal\\server\\utils/**)", + "Read(/Z:\\Repos\\monacousa-portal\\pages\\admin\\payments/**)", + "Read(/Z:\\Repos\\monacousa-portal\\server\\utils/**)", + "Read(/Z:\\Repos\\monacousa-portal\\pages\\admin\\members/**)", + "Read(/Z:\\Repos\\monacousa-portal\\pages\\admin\\members/**)", + "Read(/Z:\\Repos\\monacousa-portal\\components/**)", + "Read(/Z:\\Repos\\monacousa-portal\\components/**)", + "Read(/Z:\\Repos\\monacousa-portal\\server\\api/**)", + "Read(/Z:\\Repos\\monacousa-portal\\pages\\admin\\members/**)", + "Bash(git pull:*)" ], "deny": [], "ask": [] diff --git a/pages/admin/members/index.vue b/pages/admin/members/index.vue index ec37071..4956347 100644 --- a/pages/admin/members/index.vue +++ b/pages/admin/members/index.vue @@ -177,7 +177,7 @@ />
{{ item.first_name }} {{ item.last_name }}
-
Member ID: {{ item.member_id }}
+
ID: {{ item.member_id || `Pending (DB ID: ${item.Id})` }}
@@ -710,7 +710,8 @@ const loadMembers = async () => { const duesPaidThisYear = lastPaid && lastPaid.getFullYear() === currentYear; return { - member_id: member.Id || member.id, + ...member, // Keep all original fields including Id for API calls + member_id: member.member_id || '', // Use the actual member_id field first_name: member.first_name, last_name: member.last_name, name: `${member.last_name || ''}, ${member.first_name || ''}`.trim(), @@ -721,6 +722,8 @@ const loadMembers = async () => { dues_status: member.dues_status || (duesPaidThisYear ? 'Paid' : 'Due'), dues_paid_this_year: duesPaidThisYear, last_dues_paid: member.last_dues_paid, + membership_date_paid: member.membership_date_paid, + payment_due_date: member.payment_due_date, join_date: member.member_since || member.created_at, phone: member.phone_number || member.phone || '' }; diff --git a/pages/admin/payments/index.vue b/pages/admin/payments/index.vue index 450b691..cadd449 100644 --- a/pages/admin/payments/index.vue +++ b/pages/admin/payments/index.vue @@ -509,7 +509,7 @@ const loadPayments = async () => { // If member has dues due/overdue, create a pending payment record if (member.dues_status === 'Due' || member.dues_status === 'Overdue') { - const dueDate = member.dues_paid_until ? new Date(member.dues_paid_until) : null; + const dueDate = member.payment_due_date ? new Date(member.payment_due_date) : null; if (dueDate) { paymentRecords.push({ id: transactionCounter++, diff --git a/server/api/admin/fix-payment-dates.post.ts b/server/api/admin/fix-payment-dates.post.ts new file mode 100644 index 0000000..fb3d3b3 --- /dev/null +++ b/server/api/admin/fix-payment-dates.post.ts @@ -0,0 +1,132 @@ +// server/api/admin/fix-payment-dates.post.ts +export default defineEventHandler(async (event) => { + try { + const { getMembers, updateMember } = await import('~/server/utils/nocodb'); + + console.log('[api/admin/fix-payment-dates.post] Starting payment date migration...'); + + // Get all members + const membersResponse = await getMembers(); + const members = membersResponse?.list || []; + + if (members.length === 0) { + return { + success: true, + message: 'No members found to process', + stats: { + total: 0, + fixed: 0, + skipped: 0, + failed: 0 + } + }; + } + + const results = { + total: members.length, + fixed: 0, + skipped: 0, + failed: 0, + errors: [] as any[] + }; + + // Process each member + for (const member of members) { + try { + // Check if member has membership_date_paid but no payment_due_date + if (member.membership_date_paid && !member.payment_due_date) { + const paymentDate = new Date(member.membership_date_paid); + + // Calculate payment_due_date as 1 year from payment date + const dueDate = new Date(paymentDate); + dueDate.setFullYear(dueDate.getFullYear() + 1); + const dueDateStr = dueDate.toISOString().split('T')[0]; + + console.log(`[api/admin/fix-payment-dates.post] Fixing dates for ${member.first_name} ${member.last_name} (ID: ${member.Id})`); + console.log(` Payment Date: ${member.membership_date_paid}`); + console.log(` New Due Date: ${dueDateStr}`); + + // Update the member + await updateMember(member.Id, { + payment_due_date: dueDateStr + }); + + results.fixed++; + console.log(`[api/admin/fix-payment-dates.post] ✅ Fixed payment dates for member ${member.Id}`); + + } else if (member.membership_date_paid && member.payment_due_date) { + // Member already has both dates, skip + results.skipped++; + console.log(`[api/admin/fix-payment-dates.post] Skipped member ${member.Id} - already has payment_due_date`); + + } else if (!member.membership_date_paid && !member.payment_due_date) { + // Member hasn't paid yet, check if they're new (within grace period) + if (member.member_since) { + const joinDate = new Date(member.member_since); + const gracePeriodEnd = new Date(joinDate); + gracePeriodEnd.setMonth(gracePeriodEnd.getMonth() + 1); // 1 month grace period + + const now = new Date(); + if (now < gracePeriodEnd) { + // Still in grace period, set payment_due_date to end of grace period + const dueDateStr = gracePeriodEnd.toISOString().split('T')[0]; + + await updateMember(member.Id, { + payment_due_date: dueDateStr + }); + + results.fixed++; + console.log(`[api/admin/fix-payment-dates.post] Set grace period due date for new member ${member.Id}`); + } else { + // Past grace period, set due date to 1 year from join date + const dueDate = new Date(joinDate); + dueDate.setFullYear(dueDate.getFullYear() + 1); + const dueDateStr = dueDate.toISOString().split('T')[0]; + + await updateMember(member.Id, { + payment_due_date: dueDateStr + }); + + results.fixed++; + console.log(`[api/admin/fix-payment-dates.post] Set overdue date for member ${member.Id}`); + } + } else { + results.skipped++; + console.log(`[api/admin/fix-payment-dates.post] Skipped member ${member.Id} - no payment or join date`); + } + } else { + results.skipped++; + } + + } catch (error: any) { + console.error(`[api/admin/fix-payment-dates.post] ❌ Failed to fix dates for member ${member.Id}:`, error); + results.failed++; + results.errors.push({ + memberId: member.Id, + name: `${member.first_name} ${member.last_name}`, + error: error.message || 'Unknown error' + }); + } + } + + const message = `Payment date migration complete!\n` + + `Fixed: ${results.fixed} members\n` + + `Skipped: ${results.skipped} members\n` + + `Failed: ${results.failed} members`; + + console.log(`[api/admin/fix-payment-dates.post] ${message}`); + + return { + success: results.failed === 0, + message, + stats: results + }; + + } catch (error: any) { + console.error('[api/admin/fix-payment-dates.post] Error:', error); + throw createError({ + statusCode: error.statusCode || 500, + statusMessage: error.message || 'Failed to fix payment dates' + }); + } +}); \ No newline at end of file diff --git a/server/api/admin/generate-member-ids.post.ts b/server/api/admin/generate-member-ids.post.ts new file mode 100644 index 0000000..0d4bc7f --- /dev/null +++ b/server/api/admin/generate-member-ids.post.ts @@ -0,0 +1,119 @@ +// server/api/admin/generate-member-ids.post.ts +export default defineEventHandler(async (event) => { + try { + const body = await readBody(event).catch(() => ({})); + const { forceRegenerate = false } = body; + + const { getMembers, updateMember } = await import('~/server/utils/nocodb'); + const { generateUniqueMemberId } = await import('~/server/utils/member-id'); + + console.log('[api/admin/generate-member-ids.post] Starting member ID generation...'); + + // Get all members + const membersResponse = await getMembers(); + const members = membersResponse?.list || []; + + if (members.length === 0) { + return { + success: true, + message: 'No members found to process', + stats: { + total: 0, + generated: 0, + skipped: 0, + failed: 0 + } + }; + } + + // Filter members that need IDs (unless force regenerate) + const membersToProcess = forceRegenerate + ? members + : members.filter((member: any) => !member.member_id || member.member_id.trim() === ''); + + console.log(`[api/admin/generate-member-ids.post] Found ${membersToProcess.length} members to process (out of ${members.length} total)`); + + const results = { + total: members.length, + generated: 0, + skipped: members.length - membersToProcess.length, + failed: 0, + errors: [] as any[] + }; + + // Process each member + for (const member of membersToProcess) { + try { + // Generate unique member ID + const memberID = await generateUniqueMemberId(); + + console.log(`[api/admin/generate-member-ids.post] Generated ID ${memberID} for ${member.first_name} ${member.last_name} (ID: ${member.Id})`); + + // Update the member with the new ID + await updateMember(member.Id, { + member_id: memberID + }); + + results.generated++; + + console.log(`[api/admin/generate-member-ids.post] ✅ Successfully assigned ID ${memberID} to member ${member.Id}`); + + } catch (error: any) { + console.error(`[api/admin/generate-member-ids.post] ❌ Failed to generate ID for member ${member.Id}:`, error); + results.failed++; + results.errors.push({ + memberId: member.Id, + name: `${member.first_name} ${member.last_name}`, + error: error.message || 'Unknown error' + }); + } + } + + // Calculate and update payment_due_date for all members with membership_date_paid + console.log('[api/admin/generate-member-ids.post] Updating payment due dates for paid members...'); + + let duesDatesFixed = 0; + for (const member of members) { + if (member.membership_date_paid && !member.payment_due_date) { + try { + const paymentDate = new Date(member.membership_date_paid); + const dueDate = new Date(paymentDate); + dueDate.setFullYear(dueDate.getFullYear() + 1); + + await updateMember(member.Id, { + payment_due_date: dueDate.toISOString().split('T')[0] + }); + + duesDatesFixed++; + console.log(`[api/admin/generate-member-ids.post] Fixed payment_due_date for member ${member.Id}`); + } catch (error) { + console.error(`[api/admin/generate-member-ids.post] Failed to fix payment_due_date for member ${member.Id}:`, error); + } + } + } + + const message = `Member ID generation complete!\n` + + `Generated: ${results.generated} IDs\n` + + `Skipped: ${results.skipped} (already have IDs)\n` + + `Failed: ${results.failed}\n` + + `Payment Due Dates Fixed: ${duesDatesFixed}`; + + console.log(`[api/admin/generate-member-ids.post] ${message}`); + + return { + success: results.failed === 0, + message, + stats: { + ...results, + duesDatesFixed + } + }; + + } catch (error: any) { + console.error('[api/admin/generate-member-ids.post] Error:', error); + throw createError({ + statusCode: error.statusCode || 500, + statusMessage: error.message || 'Failed to generate member IDs' + }); + } +}); \ No newline at end of file diff --git a/server/api/members/[id]/mark-dues-paid.post.ts b/server/api/members/[id]/mark-dues-paid.post.ts index 5f1b1dc..b4e9024 100644 --- a/server/api/members/[id]/mark-dues-paid.post.ts +++ b/server/api/members/[id]/mark-dues-paid.post.ts @@ -28,14 +28,16 @@ export default defineEventHandler(async (event) => { // Determine payment date - use custom date if provided, otherwise today let paymentDate: string; + let paymentDateObj: Date; + if (customPaymentDate) { try { // Validate the custom date - const parsedDate = new Date(customPaymentDate); - if (isNaN(parsedDate.getTime())) { + paymentDateObj = new Date(customPaymentDate); + if (isNaN(paymentDateObj.getTime())) { throw new Error('Invalid date format'); } - paymentDate = parsedDate.toISOString().split('T')[0]; // YYYY-MM-DD format + paymentDate = paymentDateObj.toISOString().split('T')[0]; // YYYY-MM-DD format } catch (error) { throw createError({ statusCode: 400, @@ -44,15 +46,21 @@ export default defineEventHandler(async (event) => { } } else { // Default to today if no custom date provided - paymentDate = new Date().toISOString().split('T')[0]; + paymentDateObj = new Date(); + paymentDate = paymentDateObj.toISOString().split('T')[0]; } + // Calculate next payment due date (1 year from payment date) + const nextDueDate = new Date(paymentDateObj); + nextDueDate.setFullYear(nextDueDate.getFullYear() + 1); + const nextDueDateStr = nextDueDate.toISOString().split('T')[0]; + // Prepare update data const updateData = { current_year_dues_paid: 'true', membership_date_paid: paymentDate, - membership_status: 'Active', // Ensure member is marked as active when dues are paid - payment_due_date: undefined // Clear the due date since it's now paid + payment_due_date: nextDueDateStr, // Set to 1 year from payment date + membership_status: 'Active' // Ensure member is marked as active when dues are paid }; // Update the member diff --git a/server/utils/dues-calculator.ts b/server/utils/dues-calculator.ts index c761794..82d2786 100644 --- a/server/utils/dues-calculator.ts +++ b/server/utils/dues-calculator.ts @@ -31,14 +31,14 @@ export interface DuesPayment { /** * Calculate dues status for a member - * Uses actual dues_paid_until field from database when available + * Uses actual payment_due_date field from database when available */ export async function calculateDuesStatus(member: any): Promise { const now = new Date(); - // First check if member has dues_paid_until field - if (member.dues_paid_until) { - const paidUntil = new Date(member.dues_paid_until); + // First check if member has payment_due_date field + if (member.payment_due_date) { + const paidUntil = new Date(member.payment_due_date); const isDue = paidUntil < now; const daysUntilDue = isDue ? null : Math.floor((paidUntil.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); const daysOverdue = isDue ? Math.floor((now.getTime() - paidUntil.getTime()) / (1000 * 60 * 60 * 24)) : null; @@ -174,7 +174,7 @@ export async function recordDuesPayment(payment: DuesPayment): Promise<{ success // Update member record with payment information await updateMember(payment.memberId, { last_dues_paid: payment.paymentDate.toISOString(), - dues_paid_until: payment.paidUntil.toISOString(), + payment_due_date: payment.paidUntil.toISOString(), dues_amount: payment.amount, last_payment_method: payment.paymentMethod, last_transaction_id: payment.transactionId @@ -309,7 +309,7 @@ export async function bulkUpdateDuesDates( for (const memberId of memberIds) { try { await updateMember(memberId, { - dues_paid_until: paidUntil.toISOString() + payment_due_date: paidUntil.toISOString() }); results.success++; } catch (error: any) { diff --git a/server/utils/member-id.ts b/server/utils/member-id.ts index 5615464..46cc60a 100644 --- a/server/utils/member-id.ts +++ b/server/utils/member-id.ts @@ -2,24 +2,44 @@ import { getMembers, updateMember } from './nocodb'; import type { Member } from '~/utils/types'; /** - * Generates a unique member ID in the format MUSA-{unique 6-digit number} + * Generates a unique member ID in the format MUSA-YYYY-XXXX + * where YYYY is the current year and XXXX is a sequential number * Checks against existing member IDs to ensure uniqueness * @returns Promise - The unique member ID */ export async function generateMemberID(): Promise { console.log('[member-id] Generating new member ID...'); + const currentYear = new Date().getFullYear(); let memberID: string; let isUnique = false; let attempts = 0; - const maxAttempts = 100; // Prevent infinite loops + const maxAttempts = 1000; // Prevent infinite loops + + // Get all existing member IDs for this year to find the highest number + const members = await getMembers(); + const memberList = Array.isArray(members) ? members : members?.list || []; + + // Filter member IDs that match the current year format + const yearPrefix = `MUSA-${currentYear}-`; + const existingYearIds = memberList + .filter((member: Member) => member.member_id?.startsWith(yearPrefix)) + .map((member: Member) => { + const parts = member.member_id!.split('-'); + return parts.length === 3 ? parseInt(parts[2], 10) : 0; + }) + .filter((num: number) => !isNaN(num)); + + // Find the highest number used this year + const highestNumber = existingYearIds.length > 0 ? Math.max(...existingYearIds) : 0; + let nextNumber = highestNumber + 1; while (!isUnique && attempts < maxAttempts) { attempts++; - // Generate a 6-digit number (100000 to 999999) - const uniqueNumber = Math.floor(Math.random() * 900000) + 100000; - memberID = `MUSA-${uniqueNumber}`; + // Format with leading zeros (4 digits) + const formattedNumber = String(nextNumber).padStart(4, '0'); + memberID = `MUSA-${currentYear}-${formattedNumber}`; console.log(`[member-id] Attempt ${attempts}: Checking uniqueness of ${memberID}`); @@ -28,7 +48,8 @@ export async function generateMemberID(): Promise { isUnique = !existingMember; if (!isUnique) { - console.log(`[member-id] ID ${memberID} already exists, generating new one...`); + console.log(`[member-id] ID ${memberID} already exists, trying next number...`); + nextNumber++; } } @@ -41,6 +62,13 @@ export async function generateMemberID(): Promise { return memberID!; } +/** + * Alias for generateMemberID to match the import in other files + */ +export async function generateUniqueMemberId(): Promise { + return generateMemberID(); +} + /** * Checks if a member ID already exists in the database * @param memberID - The member ID to check @@ -103,8 +131,8 @@ export function isValidMemberIDFormat(memberID: string): boolean { return false; } - // Check format: MUSA-{6 digits} - const memberIDRegex = /^MUSA-\d{6}$/; + // Check format: MUSA-YYYY-XXXX (year and 4 digits) + const memberIDRegex = /^MUSA-\d{4}-\d{4}$/; return memberIDRegex.test(memberID); }