623 lines
18 KiB
Vue
623 lines
18 KiB
Vue
<template>
|
|
<v-banner
|
|
v-if="showBanner"
|
|
:color="isOverdue ? 'error' : 'warning'"
|
|
:icon="isOverdue ? 'mdi-alert-octagon' : 'mdi-alert-circle'"
|
|
:class="['dues-payment-banner', { 'overdue-banner': isOverdue }]"
|
|
>
|
|
<template #text>
|
|
<div class="banner-content">
|
|
<div class="text-h6 font-weight-bold mb-2">
|
|
<v-icon left>{{ isOverdue ? 'mdi-alert-octagon' : 'mdi-credit-card-alert' }}</v-icon>
|
|
{{ isOverdue ? '🚨 URGENT: Overdue Dues Payment' : 'Membership Dues Payment Required' }}
|
|
</div>
|
|
|
|
<div class="text-body-1 mb-3">
|
|
{{ paymentMessage }}
|
|
</div>
|
|
|
|
<v-card
|
|
class="payment-details-card pa-3"
|
|
color="rgba(255,255,255,0.95)"
|
|
variant="outlined"
|
|
>
|
|
<div class="text-subtitle-1 font-weight-bold mb-2 text-black">
|
|
<v-icon left size="small" class="text-black">mdi-bank</v-icon>
|
|
Payment Details
|
|
</div>
|
|
|
|
<v-row dense>
|
|
<v-col cols="12" sm="4" md="3">
|
|
<div class="text-caption font-weight-bold text-black">Amount:</div>
|
|
<div class="text-body-2 text-black">€{{ config.membershipFee }}/year</div>
|
|
</v-col>
|
|
|
|
<v-col cols="12" sm="8" md="5" v-if="config.iban">
|
|
<div class="text-caption font-weight-bold text-black">IBAN:</div>
|
|
<div class="text-body-2 font-family-monospace text-black">{{ config.iban }}</div>
|
|
</v-col>
|
|
|
|
<v-col cols="12" sm="12" md="4" v-if="config.accountHolder">
|
|
<div class="text-caption font-weight-bold text-black">Account Holder:</div>
|
|
<div class="text-body-2 text-black">{{ config.accountHolder }}</div>
|
|
</v-col>
|
|
</v-row>
|
|
|
|
<v-divider class="my-2 border-opacity-50" />
|
|
|
|
<v-row dense>
|
|
<v-col cols="12">
|
|
<div class="text-caption font-weight-bold text-black">Payment Reference:</div>
|
|
<div class="text-body-2 font-family-monospace text-black" style="background-color: rgba(0, 0, 0, 0.1); padding: 8px; border-radius: 4px; border-left: 4px solid #000000;">
|
|
{{ memberData?.member_id || 'Member ID pending' }}
|
|
</div>
|
|
<div class="text-caption text-black mt-1">
|
|
<v-icon size="small" class="mr-1 text-black">mdi-information-outline</v-icon>
|
|
Please include your member ID in the wire transfer reference for identification
|
|
</div>
|
|
</v-col>
|
|
</v-row>
|
|
|
|
<v-divider class="my-2 border-opacity-50" />
|
|
|
|
<div class="text-caption d-flex align-center text-black">
|
|
<v-icon size="small" class="mr-1 text-black">mdi-information-outline</v-icon>
|
|
{{ daysRemaining > 0 ? `${daysRemaining} days remaining` : 'Payment overdue' }}
|
|
before account suspension
|
|
</div>
|
|
</v-card>
|
|
</div>
|
|
</template>
|
|
|
|
<template #actions>
|
|
<v-btn
|
|
v-if="isAdmin"
|
|
color="white"
|
|
variant="outlined"
|
|
size="small"
|
|
@click="markAsPaidDialog = true"
|
|
class="mr-2"
|
|
>
|
|
<v-icon left size="small">mdi-check-circle</v-icon>
|
|
Mark as Paid
|
|
</v-btn>
|
|
|
|
<v-btn
|
|
color="white"
|
|
variant="text"
|
|
size="small"
|
|
@click="dismissBanner"
|
|
>
|
|
<v-icon left size="small">mdi-close</v-icon>
|
|
Dismiss
|
|
</v-btn>
|
|
</template>
|
|
</v-banner>
|
|
|
|
<!-- Mark as Paid Dialog -->
|
|
<v-dialog v-model="markAsPaidDialog" max-width="400">
|
|
<v-card>
|
|
<v-card-title class="text-h6 pa-4">
|
|
<v-icon left color="success">mdi-calendar-check</v-icon>
|
|
Mark Dues as Paid
|
|
</v-card-title>
|
|
|
|
<v-card-text class="pa-4">
|
|
<div class="mb-4">
|
|
<h4 class="text-subtitle-1 mb-2">
|
|
{{ memberData?.FullName || `${memberData?.first_name || ''} ${memberData?.last_name || ''}`.trim() }}
|
|
</h4>
|
|
<p class="text-body-2 text-medium-emphasis">
|
|
Select the date when the dues payment was received:
|
|
</p>
|
|
</div>
|
|
|
|
<v-text-field
|
|
v-model="selectedPaymentDate"
|
|
label="Payment Date*"
|
|
type="date"
|
|
:rules="[
|
|
v => !!v || 'Payment date is required',
|
|
v => !v || new Date(v).getTime() <= new Date().setHours(23,59,59,999) || 'Payment date cannot be in the future'
|
|
]"
|
|
variant="outlined"
|
|
prepend-inner-icon="mdi-calendar"
|
|
required
|
|
:max="new Date().toISOString().split('T')[0]"
|
|
hint="Select the date when the payment was received"
|
|
persistent-hint
|
|
/>
|
|
|
|
<v-alert
|
|
v-if="selectedPaymentDate && isDateInFuture"
|
|
type="warning"
|
|
variant="tonal"
|
|
class="mt-2"
|
|
density="compact"
|
|
>
|
|
<v-icon start>mdi-information</v-icon>
|
|
Future dates are not allowed. Please select today or an earlier date.
|
|
</v-alert>
|
|
</v-card-text>
|
|
|
|
<v-card-actions class="pa-4 pt-0">
|
|
<v-spacer />
|
|
<v-btn
|
|
color="grey"
|
|
variant="text"
|
|
@click="cancelPaymentDialog"
|
|
>
|
|
Cancel
|
|
</v-btn>
|
|
<v-btn
|
|
color="success"
|
|
variant="elevated"
|
|
:disabled="!selectedPaymentDate || isDateInFuture"
|
|
:loading="updating"
|
|
@click="markDuesAsPaid"
|
|
>
|
|
<v-icon start>mdi-check-circle</v-icon>
|
|
Confirm Payment
|
|
</v-btn>
|
|
</v-card-actions>
|
|
</v-card>
|
|
</v-dialog>
|
|
|
|
<!-- Snackbar for notifications -->
|
|
<v-snackbar
|
|
v-model="snackbar.show"
|
|
:color="snackbar.color"
|
|
:timeout="4000"
|
|
>
|
|
{{ snackbar.message }}
|
|
<template #actions>
|
|
<v-btn
|
|
variant="text"
|
|
@click="snackbar.show = false"
|
|
>
|
|
Close
|
|
</v-btn>
|
|
</template>
|
|
</v-snackbar>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import type { RegistrationConfig, Member } from '~/utils/types';
|
|
import {
|
|
isPaymentOverOneYear as checkPaymentOverOneYear,
|
|
isDuesActuallyCurrent as checkDuesActuallyCurrent,
|
|
calculateOverdueDays
|
|
} from '~/utils/dues-calculations';
|
|
|
|
// Get auth state
|
|
const { user, isAdmin } = useAuth();
|
|
|
|
// Reactive state
|
|
const showBanner = ref(false);
|
|
const dismissed = ref(false);
|
|
const markAsPaidDialog = ref(false);
|
|
const updating = ref(false);
|
|
const memberData = ref<Member | null>(null);
|
|
const config = ref<RegistrationConfig>({
|
|
membershipFee: 50,
|
|
iban: '',
|
|
accountHolder: ''
|
|
});
|
|
|
|
// Reactive state for payment date dialog
|
|
const selectedPaymentDate = ref('');
|
|
const selectedPaymentModel = ref<Date | null>(null);
|
|
|
|
const snackbar = ref({
|
|
show: false,
|
|
message: '',
|
|
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 standardized dues calculation function
|
|
*/
|
|
const isPaymentOverOneYear = computed(() => {
|
|
if (!memberData.value) return false;
|
|
return checkPaymentOverOneYear(memberData.value);
|
|
});
|
|
|
|
/**
|
|
* Calculate next dues date (1 year from when they last paid or joined)
|
|
*/
|
|
const nextDuesDate = computed(() => {
|
|
if (!memberData.value) return null;
|
|
|
|
// If dues are paid, calculate 1 year from payment date
|
|
if (memberData.value.current_year_dues_paid === 'true' && memberData.value.membership_date_paid) {
|
|
const lastPaidDate = new Date(memberData.value.membership_date_paid);
|
|
const nextDue = new Date(lastPaidDate);
|
|
nextDue.setFullYear(nextDue.getFullYear() + 1);
|
|
return nextDue;
|
|
}
|
|
|
|
// If not paid but has a due date, use that
|
|
if (memberData.value.payment_due_date) {
|
|
return new Date(memberData.value.payment_due_date);
|
|
}
|
|
|
|
// Fallback: 1 year from member since date
|
|
if (memberData.value.member_since) {
|
|
const memberSince = new Date(memberData.value.member_since);
|
|
const nextDue = new Date(memberSince);
|
|
nextDue.setFullYear(nextDue.getFullYear() + 1);
|
|
return nextDue;
|
|
}
|
|
|
|
return null;
|
|
});
|
|
|
|
/**
|
|
* Check if dues are coming due within 30 days (for paid members)
|
|
*/
|
|
const isDueSoon = computed(() => {
|
|
if (!memberData.value || !nextDuesDate.value) return false;
|
|
|
|
// Only show warning if dues are currently paid
|
|
if (memberData.value.current_year_dues_paid !== 'true') return false;
|
|
|
|
const today = new Date();
|
|
const thirtyDaysFromNow = new Date();
|
|
thirtyDaysFromNow.setDate(thirtyDaysFromNow.getDate() + 30);
|
|
|
|
// Show banner if due date is within the next 30 days
|
|
return nextDuesDate.value <= thirtyDaysFromNow && nextDuesDate.value > today;
|
|
});
|
|
|
|
/**
|
|
* Check if dues are overdue
|
|
* Uses standardized dues calculation function
|
|
*/
|
|
const isDuesOverdue = computed(() => {
|
|
if (!memberData.value) return false;
|
|
|
|
// Use the standardized function - if not current, then overdue
|
|
return !checkDuesActuallyCurrent(memberData.value);
|
|
});
|
|
|
|
/**
|
|
* Check if dues need to be paid (either coming due soon or overdue)
|
|
*/
|
|
const needsPayment = computed(() => {
|
|
if (!memberData.value) return false;
|
|
|
|
// Show banner if dues are coming due soon OR overdue
|
|
return isDueSoon.value || isDuesOverdue.value;
|
|
});
|
|
|
|
// Computed properties
|
|
const shouldShowBanner = computed(() => {
|
|
if (!user.value || !memberData.value) return false;
|
|
if (dismissed.value) return false;
|
|
|
|
// Show banner when payment is needed
|
|
return needsPayment.value;
|
|
});
|
|
|
|
const daysRemaining = computed(() => {
|
|
if (!nextDuesDate.value) return 0;
|
|
|
|
const dueDate = nextDuesDate.value;
|
|
const today = new Date();
|
|
const diffTime = dueDate.getTime() - today.getTime();
|
|
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
|
|
|
return diffDays; // Allow negative values for overdue
|
|
});
|
|
|
|
const isOverdue = computed(() => {
|
|
return isDuesOverdue.value;
|
|
});
|
|
|
|
const paymentMessage = computed(() => {
|
|
if (isDuesOverdue.value) {
|
|
const overdueDays = Math.abs(daysRemaining.value);
|
|
return `Your annual membership dues of €${config.value.membershipFee} are ${overdueDays > 0 ? overdueDays + ' day' + (overdueDays !== 1 ? 's' : '') + ' ' : ''}overdue. Immediate payment is required to avoid account suspension.`;
|
|
} else if (isDueSoon.value) {
|
|
const dueDays = daysRemaining.value;
|
|
if (dueDays <= 7) {
|
|
return `Your annual membership dues of €${config.value.membershipFee} are due in ${dueDays} day${dueDays !== 1 ? 's' : ''}. Please pay immediately to avoid late fees.`;
|
|
} else {
|
|
return `Your annual membership dues of €${config.value.membershipFee} are due in ${dueDays} day${dueDays !== 1 ? 's' : ''}. Please pay soon to avoid account suspension.`;
|
|
}
|
|
} else {
|
|
return `Your annual membership dues of €${config.value.membershipFee} require attention.`;
|
|
}
|
|
});
|
|
|
|
const todayDate = computed(() => {
|
|
return new Date().toISOString().split('T')[0]; // YYYY-MM-DD format
|
|
});
|
|
|
|
const isDateInFuture = computed(() => {
|
|
if (!selectedPaymentDate.value) return false;
|
|
|
|
const selectedDate = new Date(selectedPaymentDate.value);
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0); // Reset time to start of day
|
|
selectedDate.setHours(0, 0, 0, 0); // Reset time to start of day
|
|
|
|
return selectedDate > today;
|
|
});
|
|
|
|
// Methods
|
|
function dismissBanner() {
|
|
dismissed.value = true;
|
|
showBanner.value = false;
|
|
|
|
// Store dismissal in localStorage (expires after 24 hours)
|
|
const dismissalData = {
|
|
timestamp: Date.now(),
|
|
userId: user.value?.id
|
|
};
|
|
localStorage.setItem('dues-banner-dismissed', JSON.stringify(dismissalData));
|
|
}
|
|
|
|
async function markDuesAsPaid() {
|
|
if (!memberData.value?.Id || !selectedPaymentDate.value || isDateInFuture.value) return;
|
|
|
|
updating.value = true;
|
|
|
|
try {
|
|
// Call the API with the selected payment date using the correct endpoint
|
|
const response = await $fetch<{
|
|
success: boolean;
|
|
data: any;
|
|
message?: string;
|
|
}>(`/api/members/${memberData.value.Id}/mark-dues-paid`, {
|
|
method: 'post',
|
|
body: {
|
|
paymentDate: selectedPaymentDate.value
|
|
}
|
|
});
|
|
|
|
if (response?.success && response.data) {
|
|
// Update local member state
|
|
if (memberData.value) {
|
|
memberData.value.current_year_dues_paid = 'true';
|
|
memberData.value.membership_date_paid = selectedPaymentDate.value;
|
|
}
|
|
|
|
// Hide banner and reset
|
|
showBanner.value = false;
|
|
markAsPaidDialog.value = false;
|
|
selectedPaymentDate.value = '';
|
|
selectedPaymentModel.value = null;
|
|
|
|
// Show success message
|
|
snackbar.value = {
|
|
show: true,
|
|
message: 'Dues marked as paid successfully!',
|
|
color: 'success'
|
|
};
|
|
}
|
|
|
|
} catch (error: any) {
|
|
console.error('Failed to mark dues as paid:', error);
|
|
snackbar.value = {
|
|
show: true,
|
|
message: 'Failed to update payment status. Please try again.',
|
|
color: 'error'
|
|
};
|
|
} finally {
|
|
updating.value = false;
|
|
}
|
|
}
|
|
|
|
// Initialize with today's date when dialog opens
|
|
watch(markAsPaidDialog, (isOpen) => {
|
|
if (isOpen) {
|
|
const today = new Date();
|
|
selectedPaymentModel.value = today;
|
|
selectedPaymentDate.value = todayDate.value;
|
|
}
|
|
});
|
|
|
|
// Date picker handler
|
|
const handleDateUpdate = (date: Date | null) => {
|
|
if (date) {
|
|
selectedPaymentDate.value = date.toISOString().split('T')[0];
|
|
}
|
|
};
|
|
|
|
const cancelPaymentDialog = () => {
|
|
markAsPaidDialog.value = false;
|
|
selectedPaymentDate.value = '';
|
|
selectedPaymentModel.value = null;
|
|
};
|
|
|
|
// Load member data for the current user from session
|
|
async function loadMemberData() {
|
|
if (!user.value) return;
|
|
|
|
try {
|
|
const response = await $fetch('/api/auth/session') as any;
|
|
if (response?.success && response?.member) {
|
|
memberData.value = response.member;
|
|
}
|
|
} catch (error) {
|
|
console.warn('Failed to load member data:', error);
|
|
}
|
|
}
|
|
|
|
// Load configuration and check banner visibility
|
|
async function loadConfig() {
|
|
try {
|
|
const response = await $fetch('/api/registration-config') as any;
|
|
if (response?.success) {
|
|
config.value = response.data;
|
|
}
|
|
} catch (error) {
|
|
console.warn('Failed to load registration config:', error);
|
|
}
|
|
}
|
|
|
|
// Check if banner was recently dismissed
|
|
function checkDismissalStatus() {
|
|
try {
|
|
const stored = localStorage.getItem('dues-banner-dismissed');
|
|
if (stored) {
|
|
const dismissalData = JSON.parse(stored);
|
|
const hoursSinceDismissal = (Date.now() - dismissalData.timestamp) / (1000 * 60 * 60);
|
|
|
|
// Reset dismissal after 24 hours or if different user
|
|
if (hoursSinceDismissal > 24 || dismissalData.userId !== user.value?.id) {
|
|
localStorage.removeItem('dues-banner-dismissed');
|
|
dismissed.value = false;
|
|
} else {
|
|
dismissed.value = true;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.warn('Failed to check dismissal status:', error);
|
|
dismissed.value = false;
|
|
}
|
|
}
|
|
|
|
// Watchers
|
|
watch(shouldShowBanner, (newVal) => {
|
|
showBanner.value = newVal;
|
|
}, { immediate: true });
|
|
|
|
watch(user, () => {
|
|
checkDismissalStatus();
|
|
loadMemberData();
|
|
}, { immediate: true });
|
|
|
|
// Initialize
|
|
onMounted(() => {
|
|
loadConfig();
|
|
checkDismissalStatus();
|
|
loadMemberData();
|
|
});
|
|
</script>
|
|
|
|
<style scoped>
|
|
.dues-payment-banner {
|
|
border-left: 4px solid #ff9800;
|
|
}
|
|
|
|
.dues-payment-banner.overdue-banner {
|
|
border-left: 4px solid #f44336;
|
|
animation: pulse-border 2s infinite;
|
|
}
|
|
|
|
@keyframes pulse-border {
|
|
0% { border-left-color: #f44336; }
|
|
50% { border-left-color: #ff5252; }
|
|
100% { border-left-color: #f44336; }
|
|
}
|
|
|
|
.banner-content {
|
|
width: 100%;
|
|
}
|
|
|
|
.payment-details-card {
|
|
backdrop-filter: blur(10px);
|
|
border: 1px solid rgba(255, 255, 255, 0.2) !important;
|
|
}
|
|
|
|
/* Date picker styling to match Vuetify */
|
|
.date-picker-wrapper {
|
|
width: 100%;
|
|
}
|
|
|
|
.date-picker-label {
|
|
font-size: 16px;
|
|
color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
|
|
font-weight: 400;
|
|
line-height: 1.5;
|
|
letter-spacing: 0.009375em;
|
|
margin-bottom: 8px;
|
|
display: block;
|
|
}
|
|
|
|
/* Style the Vue DatePicker to match Vuetify inputs */
|
|
:deep(.dp__input) {
|
|
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
|
border-radius: 4px;
|
|
padding: 16px 12px;
|
|
padding-right: 48px; /* Make room for calendar icon */
|
|
font-size: 16px;
|
|
line-height: 1.5;
|
|
background: rgb(var(--v-theme-surface));
|
|
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
|
transition: border-color 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
|
width: 100%;
|
|
min-height: 56px;
|
|
}
|
|
|
|
:deep(.dp__input:hover) {
|
|
border-color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
|
|
}
|
|
|
|
:deep(.dp__input:focus) {
|
|
border-color: rgb(var(--v-theme-primary));
|
|
border-width: 2px;
|
|
outline: none;
|
|
}
|
|
|
|
:deep(.dp__input_readonly) {
|
|
cursor: pointer;
|
|
}
|
|
|
|
/* Style the date picker dropdown */
|
|
:deep(.dp__menu) {
|
|
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
|
border-radius: 4px;
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
|
background: rgb(var(--v-theme-surface));
|
|
}
|
|
|
|
/* Primary color theming for the date picker */
|
|
:deep(.dp__primary_color) {
|
|
background-color: rgb(var(--v-theme-primary));
|
|
}
|
|
|
|
:deep(.dp__primary_text) {
|
|
color: rgb(var(--v-theme-primary));
|
|
}
|
|
|
|
:deep(.dp__active_date) {
|
|
background-color: rgb(var(--v-theme-primary));
|
|
color: rgb(var(--v-theme-on-primary));
|
|
}
|
|
|
|
:deep(.dp__today) {
|
|
border: 1px solid rgb(var(--v-theme-primary));
|
|
}
|
|
|
|
/* Mobile responsiveness */
|
|
@media (max-width: 600px) {
|
|
.banner-content .text-h6 {
|
|
font-size: 1.1rem !important;
|
|
}
|
|
|
|
.payment-details-card {
|
|
margin-top: 8px;
|
|
}
|
|
}
|
|
</style>
|