monacousa-portal/components/ViewMemberDialog.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>