2025-08-08 19:40:13 +02:00
< template >
< v -banner
v - if = "showBanner"
2025-08-15 14:48:19 +02:00
: color = "isOverdue ? 'error' : 'warning'"
: icon = "isOverdue ? 'mdi-alert-octagon' : 'mdi-alert-circle'"
: class = "['dues-payment-banner', { 'overdue-banner': isOverdue }]"
2025-08-08 19:40:13 +02:00
>
< template # text >
< div class = "banner-content" >
< div class = "text-h6 font-weight-bold mb-2" >
2025-08-15 14:48:19 +02:00
< v -icon left > { { isOverdue ? 'mdi-alert-octagon' : 'mdi-credit-card-alert' } } < / v - i c o n >
{ { isOverdue ? '🚨 URGENT: Overdue Dues Payment' : 'Membership Dues Payment Required' } }
2025-08-08 19:40:13 +02:00
< / div >
< div class = "text-body-1 mb-3" >
{ { paymentMessage } }
< / div >
< v -card
class = "payment-details-card pa-3"
2025-08-15 16:15:55 +02:00
color = "rgba(0,0,0,0.8)"
2025-08-08 19:40:13 +02:00
variant = "outlined"
>
2025-08-15 15:58:33 +02:00
< div class = "text-subtitle-1 font-weight-bold mb-2 text-white" >
< v -icon left size = "small" class = "text-white" > mdi - bank < / v - i c o n >
2025-08-08 19:40:13 +02:00
Payment Details
< / div >
< v -row dense >
< v -col cols = "12" sm = "4" md = "3" >
2025-08-15 15:58:33 +02:00
< div class = "text-caption font-weight-bold text-white" > Amount : < / div >
< div class = "text-body-2 text-white" > € { { config . membershipFee } } / year < / div >
2025-08-08 19:40:13 +02:00
< / v - c o l >
< v -col cols = "12" sm = "8" md = "5" v-if ="config.iban" >
2025-08-15 15:58:33 +02:00
< div class = "text-caption font-weight-bold text-white" > IBAN : < / div >
< div class = "text-body-2 font-family-monospace text-white" > { { config . iban } } < / div >
2025-08-08 19:40:13 +02:00
< / v - c o l >
< v -col cols = "12" sm = "12" md = "4" v-if ="config.accountHolder" >
2025-08-15 15:58:33 +02:00
< div class = "text-caption font-weight-bold text-white" > Account Holder : < / div >
< div class = "text-body-2 text-white" > { { config . accountHolder } } < / div >
2025-08-08 19:40:13 +02:00
< / v - c o l >
< / v - r o w >
2025-08-15 15:58:33 +02:00
< v -divider class = "my-2 border-opacity-50" / >
2025-08-10 16:49:23 +02:00
< v -row dense >
< v -col cols = "12" >
2025-08-15 15:58:33 +02:00
< div class = "text-caption font-weight-bold text-white" > Payment Reference : < / div >
< div class = "text-body-2 font-family-monospace text-white" style = "background-color: rgba(255, 255, 255, 0.2); padding: 8px; border-radius: 4px; border-left: 4px solid #ffffff;" >
2025-08-10 16:49:23 +02:00
{ { memberData ? . member _id || 'Member ID pending' } }
< / div >
2025-08-15 15:58:33 +02:00
< div class = "text-caption text-white mt-1" style = "opacity: 0.9;" >
< v -icon size = "small" class = "mr-1 text-white" > mdi - information - outline < / v - i c o n >
2025-08-10 16:49:23 +02:00
Please include your member ID in the wire transfer reference for identification
< / div >
< / v - c o l >
< / v - r o w >
2025-08-15 15:58:33 +02:00
< v -divider class = "my-2 border-opacity-50" / >
2025-08-08 19:40:13 +02:00
2025-08-15 15:58:33 +02:00
< div class = "text-caption d-flex align-center text-white" style = "opacity: 0.9;" >
< v -icon size = "small" class = "mr-1 text-white" > mdi - information - outline < / v - i c o n >
2025-08-08 19:40:13 +02:00
{ { daysRemaining > 0 ? ` ${ daysRemaining } days remaining ` : 'Payment overdue' } }
before account suspension
< / div >
< / v - c a r d >
< / 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 - i c o n >
Mark as Paid
< / v - b t n >
< v -btn
color = "white"
variant = "text"
size = "small"
@ click = "dismissBanner"
>
< v -icon left size = "small" > mdi - close < / v - i c o n >
Dismiss
< / v - b t n >
< / template >
< / v - b a n n e r >
<!-- Mark as Paid Dialog -- >
< v -dialog v-model ="markAsPaidDialog" max-width="400" >
< v -card >
2025-08-15 16:02:14 +02:00
< v -card -title class = "text-h6 pa-4" >
< v -icon left color = "success" > mdi - calendar - check < / v - i c o n >
2025-08-08 19:40:13 +02:00
Mark Dues as Paid
< / v - c a r d - t i t l e >
2025-08-15 16:02:14 +02:00
< 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 >
2025-08-15 16:15:55 +02:00
< 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
/ >
2025-08-15 16:02:14 +02:00
< v -alert
v - if = "selectedPaymentDate && isDateInFuture"
type = "warning"
variant = "tonal"
class = "mt-2"
density = "compact"
>
< v -icon start > mdi - information < / v - i c o n >
Future dates are not allowed . Please select today or an earlier date .
< / v - a l e r t >
2025-08-08 19:40:13 +02:00
< / v - c a r d - t e x t >
2025-08-15 16:02:14 +02:00
< v -card -actions class = "pa-4 pt-0" >
2025-08-08 19:40:13 +02:00
< v -spacer / >
< v -btn
color = "grey"
variant = "text"
2025-08-15 16:02:14 +02:00
@ click = "cancelPaymentDialog"
2025-08-08 19:40:13 +02:00
>
Cancel
< / v - b t n >
< v -btn
color = "success"
2025-08-15 16:02:14 +02:00
variant = "elevated"
: disabled = "!selectedPaymentDate || isDateInFuture"
2025-08-08 19:40:13 +02:00
: loading = "updating"
@ click = "markDuesAsPaid"
>
2025-08-15 16:02:14 +02:00
< v -icon start > mdi - check - circle < / v - i c o n >
Confirm Payment
2025-08-08 19:40:13 +02:00
< / v - b t n >
< / v - c a r d - a c t i o n s >
< / v - c a r d >
< / v - d i a l o g >
<!-- 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 - b t n >
< / template >
< / v - s n a c k b a r >
< / template >
< script setup lang = "ts" >
import type { RegistrationConfig , Member } from '~/utils/types' ;
2025-08-15 15:55:01 +02:00
import {
isPaymentOverOneYear as checkPaymentOverOneYear ,
isDuesActuallyCurrent as checkDuesActuallyCurrent ,
calculateOverdueDays
} from '~/utils/dues-calculations' ;
2025-08-08 19:40:13 +02:00
// 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 : ''
} ) ;
2025-08-15 16:02:14 +02:00
// Reactive state for payment date dialog
const selectedPaymentDate = ref ( '' ) ;
const selectedPaymentModel = ref < Date | null > ( null ) ;
2025-08-08 19:40:13 +02:00
const snackbar = ref ( {
show : false ,
message : '' ,
color : 'success'
} ) ;
2025-08-11 15:29:42 +02:00
/ * *
* 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
2025-08-15 15:55:01 +02:00
* Uses standardized dues calculation function
2025-08-11 15:29:42 +02:00
* /
const isPaymentOverOneYear = computed ( ( ) => {
2025-08-15 15:55:01 +02:00
if ( ! memberData . value ) return false ;
return checkPaymentOverOneYear ( memberData . value ) ;
2025-08-11 15:29:42 +02:00
} ) ;
/ * *
2025-08-15 15:02:56 +02:00
* Calculate next dues date ( 1 year from when they last paid or joined )
2025-08-15 14:48:19 +02:00
* /
2025-08-15 15:02:56 +02:00
const nextDuesDate = computed ( ( ) => {
if ( ! memberData . value ) return null ;
2025-08-15 14:48:19 +02:00
2025-08-15 15:02:56 +02:00
// 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 ;
2025-08-15 14:48:19 +02:00
}
2025-08-15 15:02:56 +02:00
// 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 ;
2025-08-15 14:48:19 +02:00
} ) ;
/ * *
2025-08-15 15:02:56 +02:00
* Check if dues are coming due within 30 days ( for paid members )
2025-08-11 15:29:42 +02:00
* /
2025-08-15 15:02:56 +02:00
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
2025-08-15 15:55:01 +02:00
* Uses standardized dues calculation function
2025-08-15 15:02:56 +02:00
* /
const isDuesOverdue = computed ( ( ) => {
2025-08-11 15:29:42 +02:00
if ( ! memberData . value ) return false ;
2025-08-15 15:55:01 +02:00
// Use the standardized function - if not current, then overdue
return ! checkDuesActuallyCurrent ( memberData . value ) ;
2025-08-15 15:02:56 +02:00
} ) ;
/ * *
* 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 ;
2025-08-11 15:29:42 +02:00
} ) ;
2025-08-08 19:40:13 +02:00
// Computed properties
const shouldShowBanner = computed ( ( ) => {
if ( ! user . value || ! memberData . value ) return false ;
if ( dismissed . value ) return false ;
2025-08-12 04:25:35 +02:00
// Show banner when payment is needed
return needsPayment . value ;
2025-08-08 19:40:13 +02:00
} ) ;
const daysRemaining = computed ( ( ) => {
2025-08-15 15:02:56 +02:00
if ( ! nextDuesDate . value ) return 0 ;
2025-08-08 19:40:13 +02:00
2025-08-15 15:02:56 +02:00
const dueDate = nextDuesDate . value ;
2025-08-08 19:40:13 +02:00
const today = new Date ( ) ;
const diffTime = dueDate . getTime ( ) - today . getTime ( ) ;
const diffDays = Math . ceil ( diffTime / ( 1000 * 60 * 60 * 24 ) ) ;
2025-08-15 14:48:19 +02:00
return diffDays ; // Allow negative values for overdue
} ) ;
const isOverdue = computed ( ( ) => {
2025-08-15 15:02:56 +02:00
return isDuesOverdue . value ;
2025-08-08 19:40:13 +02:00
} ) ;
const paymentMessage = computed ( ( ) => {
2025-08-15 15:02:56 +02:00
if ( isDuesOverdue . value ) {
2025-08-15 14:48:19 +02:00
const overdueDays = Math . abs ( daysRemaining . value ) ;
2025-08-15 15:02:56 +02:00
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. ` ;
}
2025-08-08 19:40:13 +02:00
} else {
2025-08-15 15:02:56 +02:00
return ` Your annual membership dues of € ${ config . value . membershipFee } require attention. ` ;
2025-08-08 19:40:13 +02:00
}
} ) ;
2025-08-15 16:02:14 +02:00
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 ;
} ) ;
2025-08-08 19:40:13 +02:00
// 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 ( ) {
2025-08-15 16:02:14 +02:00
if ( ! memberData . value ? . Id || ! selectedPaymentDate . value || isDateInFuture . value ) return ;
2025-08-08 19:40:13 +02:00
updating . value = true ;
try {
2025-08-15 16:10:12 +02:00
// 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' ,
2025-08-08 19:40:13 +02:00
body : {
2025-08-15 16:10:12 +02:00
paymentDate : selectedPaymentDate . value
2025-08-08 19:40:13 +02:00
}
} ) ;
2025-08-15 16:10:12 +02:00
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'
} ;
2025-08-08 19:40:13 +02:00
}
} 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 ;
}
}
2025-08-15 16:10:12 +02:00
// Initialize with today's date when dialog opens
watch ( markAsPaidDialog , ( isOpen ) => {
if ( isOpen ) {
const today = new Date ( ) ;
selectedPaymentModel . value = today ;
selectedPaymentDate . value = todayDate . value ;
}
} ) ;
2025-08-15 16:02:14 +02:00
// 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 ;
} ;
2025-08-15 15:02:56 +02:00
// Load member data for the current user from session
2025-08-08 19:40:13 +02:00
async function loadMemberData ( ) {
2025-08-15 15:02:56 +02:00
if ( ! user . value ) return ;
2025-08-08 19:40:13 +02:00
try {
2025-08-15 15:02:56 +02:00
const response = await $fetch ( '/api/auth/session' ) as any ;
if ( response ? . success && response ? . member ) {
memberData . value = response . member ;
2025-08-08 19:40:13 +02:00
}
} catch ( error ) {
console . warn ( 'Failed to load member data:' , error ) ;
}
}
// Load configuration and check banner visibility
async function loadConfig ( ) {
try {
2025-08-12 04:25:35 +02:00
const response = await $fetch ( '/api/registration-config' ) as any ;
2025-08-08 19:40:13 +02:00
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 : 4 px solid # ff9800 ;
}
2025-08-15 14:48:19 +02:00
. dues - payment - banner . overdue - banner {
border - left : 4 px solid # f44336 ;
animation : pulse - border 2 s infinite ;
}
@ keyframes pulse - border {
0 % { border - left - color : # f44336 ; }
50 % { border - left - color : # ff5252 ; }
100 % { border - left - color : # f44336 ; }
}
2025-08-08 19:40:13 +02:00
. banner - content {
width : 100 % ;
}
. payment - details - card {
backdrop - filter : blur ( 10 px ) ;
border : 1 px solid rgba ( 255 , 255 , 255 , 0.2 ) ! important ;
}
2025-08-15 16:02:14 +02:00
/* Date picker styling to match Vuetify */
. date - picker - wrapper {
width : 100 % ;
}
. date - picker - label {
font - size : 16 px ;
color : rgba ( var ( -- v - theme - on - surface ) , var ( -- v - medium - emphasis - opacity ) ) ;
font - weight : 400 ;
line - height : 1.5 ;
letter - spacing : 0.009375 em ;
margin - bottom : 8 px ;
display : block ;
}
/* Style the Vue DatePicker to match Vuetify inputs */
: deep ( . dp _ _input ) {
border : 1 px solid rgba ( var ( -- v - border - color ) , var ( -- v - border - opacity ) ) ;
border - radius : 4 px ;
padding : 16 px 12 px ;
padding - right : 48 px ; /* Make room for calendar icon */
font - size : 16 px ;
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.2 s cubic - bezier ( 0.4 , 0 , 0.2 , 1 ) ;
width : 100 % ;
min - height : 56 px ;
}
: 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 : 2 px ;
outline : none ;
}
: deep ( . dp _ _input _readonly ) {
cursor : pointer ;
}
/* Style the date picker dropdown */
: deep ( . dp _ _menu ) {
border : 1 px solid rgba ( var ( -- v - border - color ) , var ( -- v - border - opacity ) ) ;
border - radius : 4 px ;
box - shadow : 0 2 px 8 px 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 : 1 px solid rgb ( var ( -- v - theme - primary ) ) ;
}
2025-08-08 19:40:13 +02:00
/* Mobile responsiveness */
@ media ( max - width : 600 px ) {
. banner - content . text - h6 {
font - size : 1.1 rem ! important ;
}
. payment - details - card {
margin - top : 8 px ;
}
}
< / style >