634 lines
19 KiB
Vue
634 lines
19 KiB
Vue
<template>
|
|
<v-dialog
|
|
:model-value="modelValue"
|
|
@update:model-value="$emit('update:model-value', $event)"
|
|
max-width="600"
|
|
persistent
|
|
scrollable
|
|
>
|
|
<v-card v-if="member">
|
|
<!-- Header -->
|
|
<v-card-title class="d-flex align-center pa-6 bg-primary">
|
|
<ProfileAvatar
|
|
:member-id="member.member_id"
|
|
:member-name="member.FullName || `${member.first_name} ${member.last_name}`"
|
|
:first-name="member.first_name"
|
|
:last-name="member.last_name"
|
|
size="large"
|
|
class="mr-4"
|
|
clickable
|
|
show-border
|
|
@click="openImageLightbox"
|
|
/>
|
|
|
|
<div class="flex-grow-1">
|
|
<h2 class="text-h5 text-white font-weight-bold">
|
|
{{ member.FullName || `${member.first_name} ${member.last_name}` }}
|
|
</h2>
|
|
<div class="d-flex align-center mt-1">
|
|
<CountryFlag
|
|
v-if="member.nationality"
|
|
:country-code="member.nationality"
|
|
:show-name="false"
|
|
size="small"
|
|
class="mr-2"
|
|
/>
|
|
<span class="text-white text-body-2">
|
|
{{ getCountryName(member.nationality) || 'Unknown Country' }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<v-btn
|
|
icon
|
|
variant="text"
|
|
color="white"
|
|
@click="$emit('update:model-value', false)"
|
|
>
|
|
<v-icon>mdi-close</v-icon>
|
|
</v-btn>
|
|
</v-card-title>
|
|
|
|
<!-- Status Chips -->
|
|
<v-card-text class="py-4">
|
|
<div class="d-flex flex-wrap gap-2 mb-4">
|
|
<v-chip
|
|
:color="statusColor"
|
|
variant="flat"
|
|
size="small"
|
|
>
|
|
<v-icon start size="16">{{ statusIcon }}</v-icon>
|
|
{{ member.membership_status }}
|
|
</v-chip>
|
|
|
|
<v-chip
|
|
:color="duesColor"
|
|
:variant="duesVariant"
|
|
size="small"
|
|
>
|
|
<v-icon start size="16">{{ duesIcon }}</v-icon>
|
|
{{ duesText }}
|
|
</v-chip>
|
|
|
|
<v-chip
|
|
v-if="member.payment_due_date"
|
|
:color="isOverdue ? 'error' : 'warning'"
|
|
variant="tonal"
|
|
size="small"
|
|
>
|
|
<v-icon start size="16">mdi-calendar-alert</v-icon>
|
|
{{ isOverdue ? 'Payment Overdue' : 'Payment Due' }}
|
|
</v-chip>
|
|
</div>
|
|
|
|
<!-- Member Information -->
|
|
<v-row>
|
|
<!-- Personal Information -->
|
|
<v-col cols="12" md="6">
|
|
<h3 class="text-h6 mb-3 text-primary">Personal Information</h3>
|
|
|
|
<div class="info-group">
|
|
<div class="info-item mb-3">
|
|
<label class="text-body-2 font-weight-bold text-medium-emphasis">First Name</label>
|
|
<p class="text-body-1 ma-0">{{ member.first_name || 'Not provided' }}</p>
|
|
</div>
|
|
|
|
<div class="info-item mb-3">
|
|
<label class="text-body-2 font-weight-bold text-medium-emphasis">Last Name</label>
|
|
<p class="text-body-1 ma-0">{{ member.last_name || 'Not provided' }}</p>
|
|
</div>
|
|
|
|
<div class="info-item mb-3">
|
|
<label class="text-body-2 font-weight-bold text-medium-emphasis">Email</label>
|
|
<p class="text-body-1 ma-0">
|
|
<a v-if="member.email" :href="`mailto:${member.email}`" class="text-primary">
|
|
{{ member.email }}
|
|
</a>
|
|
<span v-else>Not provided</span>
|
|
</p>
|
|
</div>
|
|
|
|
<div class="info-item mb-3" v-if="member.phone">
|
|
<label class="text-body-2 font-weight-bold text-medium-emphasis">Phone</label>
|
|
<p class="text-body-1 ma-0">
|
|
<a :href="`tel:${member.phone}`" class="text-primary">
|
|
{{ member.FormattedPhone || member.phone }}
|
|
</a>
|
|
</p>
|
|
</div>
|
|
|
|
<div class="info-item mb-3" v-if="member.date_of_birth">
|
|
<label class="text-body-2 font-weight-bold text-medium-emphasis">Date of Birth</label>
|
|
<p class="text-body-1 ma-0">{{ formatDate(member.date_of_birth) }}</p>
|
|
</div>
|
|
|
|
<div class="info-item mb-3" v-if="member.address">
|
|
<label class="text-body-2 font-weight-bold text-medium-emphasis">Address</label>
|
|
<p class="text-body-1 ma-0">{{ member.address }}</p>
|
|
</div>
|
|
</div>
|
|
</v-col>
|
|
|
|
<!-- Membership Information -->
|
|
<v-col cols="12" md="6">
|
|
<h3 class="text-h6 mb-3 text-primary">Membership Information</h3>
|
|
|
|
<div class="info-group">
|
|
<div class="info-item mb-3">
|
|
<label class="text-body-2 font-weight-bold text-medium-emphasis">Member Since</label>
|
|
<p class="text-body-1 ma-0">{{ formatDate(member.member_since) || 'Not specified' }}</p>
|
|
</div>
|
|
|
|
<div class="info-item mb-3">
|
|
<label class="text-body-2 font-weight-bold text-medium-emphasis">Membership Status</label>
|
|
<p class="text-body-1 ma-0">
|
|
<v-chip :color="statusColor" size="small" variant="tonal">
|
|
{{ member.membership_status }}
|
|
</v-chip>
|
|
</p>
|
|
</div>
|
|
|
|
<div class="info-item mb-3">
|
|
<label class="text-body-2 font-weight-bold text-medium-emphasis">Current Year Dues</label>
|
|
<p class="text-body-1 ma-0">
|
|
<v-chip :color="duesColor" size="small" variant="tonal">
|
|
{{ member.current_year_dues_paid === 'true' ? 'Paid' : 'Outstanding' }}
|
|
</v-chip>
|
|
</p>
|
|
</div>
|
|
|
|
<div class="info-item mb-3" v-if="member.membership_date_paid">
|
|
<label class="text-body-2 font-weight-bold text-medium-emphasis">Last Payment Date</label>
|
|
<p class="text-body-1 ma-0">{{ formatDate(member.membership_date_paid) }}</p>
|
|
</div>
|
|
|
|
<div class="info-item mb-3" v-if="member.payment_due_date">
|
|
<label class="text-body-2 font-weight-bold text-medium-emphasis">Payment Due Date</label>
|
|
<p class="text-body-1 ma-0" :class="{ 'text-error': isOverdue }">
|
|
{{ formatDate(member.payment_due_date) }}
|
|
<span v-if="isOverdue" class="text-error font-weight-bold"> (Overdue)</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</v-col>
|
|
</v-row>
|
|
</v-card-text>
|
|
|
|
<!-- Actions -->
|
|
<v-card-actions class="pa-6 pt-0">
|
|
<!-- Email Button (Board/Admin only for overdue/due soon members) -->
|
|
<v-btn
|
|
v-if="shouldShowEmailButton"
|
|
variant="outlined"
|
|
:color="isOverdue ? 'error' : 'warning'"
|
|
:loading="emailLoading"
|
|
@click="sendDuesReminder"
|
|
>
|
|
<v-icon start>mdi-email-alert</v-icon>
|
|
Send Dues Reminder
|
|
</v-btn>
|
|
|
|
<!-- Mark Dues as Paid Button (Board/Admin only) -->
|
|
<v-btn
|
|
v-if="shouldShowMarkAsPaidButton"
|
|
variant="outlined"
|
|
color="success"
|
|
@click="showPaymentDateDialog = true"
|
|
>
|
|
<v-icon start>mdi-cash</v-icon>
|
|
Mark Dues as Paid
|
|
</v-btn>
|
|
|
|
<v-spacer />
|
|
<v-btn
|
|
variant="text"
|
|
@click="$emit('update:model-value', false)"
|
|
>
|
|
Close
|
|
</v-btn>
|
|
<v-btn
|
|
color="primary"
|
|
variant="elevated"
|
|
@click="$emit('edit', member)"
|
|
>
|
|
<v-icon start>mdi-pencil</v-icon>
|
|
Edit
|
|
</v-btn>
|
|
</v-card-actions>
|
|
</v-card>
|
|
</v-dialog>
|
|
|
|
<!-- Image Lightbox -->
|
|
<v-dialog
|
|
v-model="showImageLightbox"
|
|
max-width="800"
|
|
@click:outside="showImageLightbox = false"
|
|
>
|
|
<v-card class="pa-0" v-if="member && lightboxImageUrl">
|
|
<v-card-title class="d-flex align-center pa-4">
|
|
<span class="text-h6">{{ member.FullName || `${member.first_name} ${member.last_name}` }} - Profile Photo</span>
|
|
<v-spacer />
|
|
<v-btn
|
|
icon
|
|
variant="text"
|
|
@click="showImageLightbox = false"
|
|
>
|
|
<v-icon>mdi-close</v-icon>
|
|
</v-btn>
|
|
</v-card-title>
|
|
<v-card-text class="pa-4">
|
|
<div class="text-center">
|
|
<v-img
|
|
:src="lightboxImageUrl"
|
|
:alt="`${member.FullName || `${member.first_name} ${member.last_name}`} profile photo`"
|
|
max-height="500"
|
|
contain
|
|
class="mx-auto"
|
|
/>
|
|
</div>
|
|
</v-card-text>
|
|
</v-card>
|
|
</v-dialog>
|
|
|
|
<!-- Payment Date Selection Dialog -->
|
|
<v-dialog v-model="showPaymentDateDialog" 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">
|
|
{{ member?.FullName || `${member?.first_name} ${member?.last_name}` }}
|
|
</h4>
|
|
<p class="text-body-2 text-medium-emphasis">
|
|
Select the date when the dues payment was received:
|
|
</p>
|
|
</div>
|
|
|
|
<div class="date-picker-wrapper">
|
|
<v-text-field
|
|
v-model="selectedPaymentDate"
|
|
label="Payment Date"
|
|
type="date"
|
|
:max="todayDate"
|
|
variant="outlined"
|
|
density="comfortable"
|
|
prepend-inner-icon="mdi-calendar"
|
|
:error="isDateInFuture"
|
|
:error-messages="isDateInFuture ? 'Future dates are not allowed' : ''"
|
|
@update:model-value="handleDateUpdate"
|
|
/>
|
|
<div class="text-caption text-medium-emphasis mt-1">
|
|
Select the date when the payment was received (Monaco timezone)
|
|
</div>
|
|
</div>
|
|
|
|
<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"
|
|
@click="markDuesAsPaid"
|
|
>
|
|
<v-icon start>mdi-check-circle</v-icon>
|
|
Confirm Payment
|
|
</v-btn>
|
|
</v-card-actions>
|
|
</v-card>
|
|
</v-dialog>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import type { Member } from '~/utils/types';
|
|
import { getCountryName } from '~/utils/countries';
|
|
|
|
interface Props {
|
|
modelValue: boolean;
|
|
member?: Member | null;
|
|
}
|
|
|
|
interface Emits {
|
|
(e: 'update:model-value', value: boolean): void;
|
|
(e: 'edit', member: Member): void;
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
member: null
|
|
});
|
|
|
|
defineEmits<Emits>();
|
|
|
|
// Lightbox state
|
|
const showImageLightbox = ref(false);
|
|
const lightboxImageUrl = ref<string | null>(null);
|
|
|
|
// Email functionality
|
|
const emailLoading = ref(false);
|
|
|
|
// Payment dialog state
|
|
const showPaymentDateDialog = ref(false);
|
|
const selectedPaymentDate = ref('');
|
|
const selectedPaymentModel = ref<Date | null>(null);
|
|
|
|
// Auth composable
|
|
const { user, isAdmin, isBoard } = useAuth();
|
|
|
|
// Computed properties
|
|
const memberInitials = computed(() => {
|
|
if (!props.member) return '';
|
|
const firstName = props.member.first_name || '';
|
|
const lastName = props.member.last_name || '';
|
|
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase();
|
|
});
|
|
|
|
const avatarColor = computed(() => {
|
|
if (!props.member) return 'grey';
|
|
const colors = ['primary', 'secondary', 'accent', 'info', 'warning', 'success'];
|
|
const idNumber = parseInt(props.member.Id) || 0;
|
|
return colors[idNumber % colors.length];
|
|
});
|
|
|
|
const statusColor = computed(() => {
|
|
if (!props.member) return 'grey';
|
|
const status = props.member.membership_status;
|
|
switch (status) {
|
|
case 'Active': return 'success';
|
|
case 'Inactive': return 'grey';
|
|
case 'Pending': return 'warning';
|
|
case 'Expired': return 'error';
|
|
default: return 'grey';
|
|
}
|
|
});
|
|
|
|
const statusIcon = computed(() => {
|
|
if (!props.member) return 'mdi-help';
|
|
const status = props.member.membership_status;
|
|
switch (status) {
|
|
case 'Active': return 'mdi-check-circle';
|
|
case 'Inactive': return 'mdi-pause-circle';
|
|
case 'Pending': return 'mdi-clock';
|
|
case 'Expired': return 'mdi-alert-circle';
|
|
default: return 'mdi-help';
|
|
}
|
|
});
|
|
|
|
const duesColor = computed(() => {
|
|
if (!props.member) return 'grey';
|
|
return props.member.current_year_dues_paid === 'true' ? 'success' : 'error';
|
|
});
|
|
|
|
const duesVariant = computed(() => {
|
|
if (!props.member) return 'tonal';
|
|
return props.member.current_year_dues_paid === 'true' ? 'tonal' : 'flat';
|
|
});
|
|
|
|
const duesIcon = computed(() => {
|
|
if (!props.member) return 'mdi-help';
|
|
return props.member.current_year_dues_paid === 'true' ? 'mdi-check-circle' : 'mdi-alert-circle';
|
|
});
|
|
|
|
const duesText = computed(() => {
|
|
if (!props.member) return '';
|
|
return props.member.current_year_dues_paid === 'true' ? 'Dues Paid' : 'Dues Outstanding';
|
|
});
|
|
|
|
const isOverdue = computed(() => {
|
|
if (!props.member || !props.member.payment_due_date) return false;
|
|
const dueDate = new Date(props.member.payment_due_date);
|
|
const today = new Date();
|
|
return dueDate < today && props.member.current_year_dues_paid !== 'true';
|
|
});
|
|
|
|
// Check if dues are coming due within 2 months
|
|
const isDuesComingDue = computed(() => {
|
|
if (!props.member || props.member.current_year_dues_paid !== 'true') return false;
|
|
|
|
// Calculate next due date (1 year from last payment)
|
|
if (props.member.membership_date_paid) {
|
|
const lastPaidDate = new Date(props.member.membership_date_paid);
|
|
const nextDue = new Date(lastPaidDate);
|
|
nextDue.setFullYear(nextDue.getFullYear() + 1);
|
|
|
|
const today = new Date();
|
|
const twoMonthsFromNow = new Date();
|
|
twoMonthsFromNow.setMonth(twoMonthsFromNow.getMonth() + 2);
|
|
|
|
return nextDue <= twoMonthsFromNow && nextDue > today;
|
|
}
|
|
|
|
return false;
|
|
});
|
|
|
|
const shouldShowEmailButton = computed(() => {
|
|
// Only show email button for board/admin users for members with email and dues issues
|
|
const hasPermission = isAdmin.value || isBoard.value;
|
|
const hasEmail = !!props.member?.email;
|
|
const hasDuesIssue = isOverdue.value || isDuesComingDue.value;
|
|
|
|
return hasPermission && hasEmail && hasDuesIssue;
|
|
});
|
|
|
|
const shouldShowMarkAsPaidButton = computed(() => {
|
|
// Only show mark as paid button for board/admin users for members with outstanding dues
|
|
const hasPermission = isAdmin.value || isBoard.value;
|
|
const hasOutstandingDues = props.member?.current_year_dues_paid !== 'true';
|
|
|
|
return hasPermission && hasOutstandingDues;
|
|
});
|
|
|
|
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;
|
|
});
|
|
|
|
// Initialize with today's date when dialog opens
|
|
watch(showPaymentDateDialog, (isOpen) => {
|
|
if (isOpen) {
|
|
const today = new Date();
|
|
selectedPaymentModel.value = today;
|
|
selectedPaymentDate.value = todayDate.value;
|
|
}
|
|
});
|
|
|
|
// Methods
|
|
const handleDateUpdate = (dateString: string) => {
|
|
selectedPaymentDate.value = dateString;
|
|
};
|
|
|
|
const handleDateModelUpdate = (date: Date | null) => {
|
|
if (date) {
|
|
selectedPaymentModel.value = date;
|
|
selectedPaymentDate.value = date.toISOString().split('T')[0];
|
|
} else {
|
|
selectedPaymentModel.value = null;
|
|
selectedPaymentDate.value = '';
|
|
}
|
|
};
|
|
|
|
const cancelPaymentDialog = () => {
|
|
showPaymentDateDialog.value = false;
|
|
selectedPaymentDate.value = '';
|
|
selectedPaymentModel.value = null;
|
|
};
|
|
|
|
const formatDate = (dateString: string): string => {
|
|
if (!dateString) return '';
|
|
|
|
try {
|
|
const date = new Date(dateString);
|
|
return date.toLocaleDateString('en-US', {
|
|
year: 'numeric',
|
|
month: 'long',
|
|
day: 'numeric'
|
|
});
|
|
} catch {
|
|
return dateString;
|
|
}
|
|
};
|
|
|
|
const sendDuesReminder = async () => {
|
|
if (!props.member?.email || emailLoading.value) return;
|
|
|
|
emailLoading.value = true;
|
|
|
|
try {
|
|
// Determine the reminder type based on the member's status
|
|
const reminderType = isOverdue.value ? 'overdue' : 'due-soon';
|
|
|
|
const response = await $fetch<{
|
|
success: boolean;
|
|
message: string;
|
|
data: any;
|
|
}>(`/api/members/${props.member.Id}/send-dues-reminder`, {
|
|
method: 'post',
|
|
body: {
|
|
reminderType
|
|
}
|
|
});
|
|
|
|
if (response?.success) {
|
|
console.log(`Dues reminder sent successfully to ${props.member.email}`);
|
|
// You could show a success toast here if needed
|
|
}
|
|
} catch (error: any) {
|
|
console.error('Error sending dues reminder:', error);
|
|
// You could show an error toast here if needed
|
|
} finally {
|
|
emailLoading.value = false;
|
|
}
|
|
};
|
|
|
|
const markDuesAsPaid = async () => {
|
|
if (!props.member || !selectedPaymentDate.value) return;
|
|
|
|
try {
|
|
const response = await $fetch<{
|
|
success: boolean;
|
|
data: Member;
|
|
message?: string;
|
|
}>(`/api/members/${props.member.Id}/mark-dues-paid`, {
|
|
method: 'post',
|
|
body: {
|
|
paymentDate: selectedPaymentDate.value
|
|
}
|
|
});
|
|
|
|
if (response?.success && response.data) {
|
|
// Update the member data
|
|
Object.assign(props.member, response.data);
|
|
|
|
// Close the dialog and reset
|
|
showPaymentDateDialog.value = false;
|
|
selectedPaymentDate.value = '';
|
|
}
|
|
} catch (error: any) {
|
|
console.error('Error marking dues as paid:', error);
|
|
// You could show an error message here if needed
|
|
}
|
|
};
|
|
|
|
const openImageLightbox = async () => {
|
|
if (!props.member?.member_id) return;
|
|
|
|
try {
|
|
// Fetch the original sized image for the lightbox
|
|
const response = await $fetch(`/api/profile/image/${props.member.member_id}/medium`) as any;
|
|
if (response?.success && response?.imageUrl) {
|
|
lightboxImageUrl.value = response.imageUrl;
|
|
showImageLightbox.value = true;
|
|
}
|
|
} catch (error) {
|
|
console.warn('Could not load image for lightbox:', error);
|
|
// Could show a snackbar here if needed
|
|
}
|
|
};
|
|
</script>
|
|
|
|
<style scoped>
|
|
.info-group {
|
|
background: rgba(var(--v-theme-surface-variant), 0.1);
|
|
border-radius: 8px;
|
|
padding: 16px;
|
|
}
|
|
|
|
.info-item {
|
|
border-bottom: 1px solid rgba(var(--v-theme-outline), 0.12);
|
|
padding-bottom: 8px;
|
|
}
|
|
|
|
.info-item:last-child {
|
|
border-bottom: none;
|
|
padding-bottom: 0;
|
|
}
|
|
|
|
.info-item label {
|
|
display: block;
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.bg-primary {
|
|
background: linear-gradient(135deg, #a31515 0%, #d32f2f 100%) !important;
|
|
}
|
|
|
|
.text-error {
|
|
color: rgb(var(--v-theme-error)) !important;
|
|
}
|
|
|
|
.text-primary {
|
|
color: #a31515 !important;
|
|
}
|
|
</style>
|