monacousa-portal/components/DuesPaymentBanner.vue

410 lines
11 KiB
Vue

<template>
<v-banner
v-if="showBanner"
color="warning"
icon="mdi-alert-circle"
sticky
class="dues-payment-banner"
>
<template #text>
<div class="banner-content">
<div class="text-h6 font-weight-bold mb-2">
<v-icon left>mdi-credit-card-alert</v-icon>
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.1)"
variant="outlined"
>
<div class="text-subtitle-1 font-weight-bold mb-2">
<v-icon left size="small">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">Amount:</div>
<div class="text-body-2">€{{ 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">IBAN:</div>
<div class="text-body-2 font-family-monospace">{{ config.iban }}</div>
</v-col>
<v-col cols="12" sm="12" md="4" v-if="config.accountHolder">
<div class="text-caption font-weight-bold">Account Holder:</div>
<div class="text-body-2">{{ config.accountHolder }}</div>
</v-col>
</v-row>
<v-divider class="my-2" />
<v-row dense>
<v-col cols="12">
<div class="text-caption font-weight-bold">Payment Reference:</div>
<div class="text-body-2 font-family-monospace" style="background-color: rgba(163, 21, 21, 0.1); padding: 8px; border-radius: 4px; border-left: 4px solid #a31515;">
{{ memberData?.member_id || 'Member ID pending' }}
</div>
<div class="text-caption text-medium-emphasis mt-1">
<v-icon size="small" class="mr-1">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" />
<div class="text-caption d-flex align-center">
<v-icon size="small" class="mr-1">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">
<v-icon left color="success">mdi-check-circle</v-icon>
Mark Dues as Paid
</v-card-title>
<v-card-text>
<p>Are you sure you want to mark the dues as paid for this member?</p>
<p class="text-body-2 text-medium-emphasis">
This will remove the payment banner and update the member's status.
</p>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
color="grey"
variant="text"
@click="markAsPaidDialog = false"
>
Cancel
</v-btn>
<v-btn
color="success"
variant="flat"
:loading="updating"
@click="markDuesAsPaid"
>
Mark as Paid
</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';
// 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: ''
});
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 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 need to be paid (either overdue or in grace period)
* Banner should show when payment is needed
*/
const needsPayment = computed(() => {
if (!memberData.value) return false;
const duesCurrentlyPaid = memberData.value.current_year_dues_paid === 'true';
const paymentTooOld = isPaymentOverOneYear.value;
// Show banner if:
// 1. Dues are not currently paid (regardless of grace period)
// 2. OR payment is over 1 year old (even if marked as paid)
return !duesCurrentlyPaid || paymentTooOld;
});
// 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 (!memberData.value?.payment_due_date) return 0;
const dueDate = new Date(memberData.value.payment_due_date);
const today = new Date();
const diffTime = dueDate.getTime() - today.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return Math.max(0, diffDays);
});
const paymentMessage = computed(() => {
if (daysRemaining.value > 30) {
return `Your annual membership dues of €${config.value.membershipFee} are due in ${daysRemaining.value} days.`;
} else if (daysRemaining.value > 0) {
return `Your annual membership dues of €${config.value.membershipFee} are due in ${daysRemaining.value} days. Please pay soon to avoid account suspension.`;
} else {
return `Your annual membership dues of €${config.value.membershipFee} are overdue. Your account may be suspended soon.`;
}
});
// 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) return;
updating.value = true;
try {
// Update member's dues status
await $fetch(`/api/members/${memberData.value.Id}`, {
method: 'PUT',
body: {
current_year_dues_paid: 'true',
membership_date_paid: new Date().toISOString(),
payment_due_date: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString() // Next year
}
});
// Update local member state
if (memberData.value) {
memberData.value.current_year_dues_paid = 'true';
memberData.value.membership_date_paid = new Date().toISOString();
}
// Hide banner
showBanner.value = false;
markAsPaidDialog.value = false;
// 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;
}
}
// Load member data for the current user
async function loadMemberData() {
if (!user.value?.email) return;
try {
const response = await $fetch('/api/members') as any;
const members = response?.data || response?.list || [];
// Find member by email
const member = members.find((m: any) => m.email === user.value?.email);
if (member) {
memberData.value = 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;
}
.banner-content {
width: 100%;
}
.payment-details-card {
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2) !important;
}
/* Mobile responsiveness */
@media (max-width: 600px) {
.banner-content .text-h6 {
font-size: 1.1rem !important;
}
.payment-details-card {
margin-top: 8px;
}
}
</style>