597 lines
16 KiB
Vue
597 lines
16 KiB
Vue
<template>
|
|
<v-card
|
|
:class="[
|
|
'dues-action-card',
|
|
status === 'overdue' ? 'dues-action-card--overdue' : 'dues-action-card--upcoming'
|
|
]"
|
|
elevation="2"
|
|
>
|
|
<!-- Status Badge -->
|
|
<div class="status-badge">
|
|
<v-chip
|
|
:color="statusColor"
|
|
size="small"
|
|
variant="flat"
|
|
>
|
|
<v-icon start size="12">{{ statusIcon }}</v-icon>
|
|
{{ statusText }}
|
|
</v-chip>
|
|
</div>
|
|
|
|
<v-card-text class="pa-4">
|
|
<!-- Member Info Header -->
|
|
<div class="d-flex align-center mb-3">
|
|
<ProfileAvatar
|
|
:member-id="member.member_id || member.Id"
|
|
:first-name="member.first_name"
|
|
:last-name="member.last_name"
|
|
:member-name="member.FullName"
|
|
size="small"
|
|
class="mr-3"
|
|
/>
|
|
|
|
<div class="flex-grow-1">
|
|
<h4 class="text-subtitle-1 font-weight-bold mb-1">
|
|
{{ member.FullName || `${member.first_name} ${member.last_name}` }}
|
|
</h4>
|
|
<div class="d-flex align-center">
|
|
<v-chip size="x-small" color="grey" variant="text" class="pa-0 mr-2">
|
|
ID: {{ member.member_id || `MUSA-${member.Id}` }}
|
|
</v-chip>
|
|
<CountryFlag
|
|
v-if="member.nationality"
|
|
:country-code="member.nationality.split(',')[0]"
|
|
:show-name="false"
|
|
size="small"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Dues Information -->
|
|
<div class="dues-info mb-3">
|
|
<div v-if="status === 'overdue'">
|
|
<!-- Overdue Information -->
|
|
<div class="d-flex justify-space-between align-center mb-2">
|
|
<span class="text-body-2 text-medium-emphasis">
|
|
<v-icon size="14" class="mr-1">mdi-clock-alert</v-icon>
|
|
Days Overdue
|
|
</span>
|
|
<span class="text-body-2 font-weight-bold text-error">
|
|
{{ calculateDisplayOverdueDays(member) }} days
|
|
</span>
|
|
</div>
|
|
|
|
<div v-if="member.overdueReason" class="overdue-reason">
|
|
<span class="text-caption text-error">
|
|
<v-icon size="12" class="mr-1">mdi-information</v-icon>
|
|
{{ member.overdueReason }}
|
|
</span>
|
|
</div>
|
|
|
|
<div v-if="member.membership_date_paid" class="d-flex justify-space-between align-center mt-2">
|
|
<span class="text-body-2 text-medium-emphasis">
|
|
<v-icon size="14" class="mr-1">mdi-calendar-check</v-icon>
|
|
Last Payment
|
|
</span>
|
|
<span class="text-body-2">
|
|
{{ formatDate(member.membership_date_paid) }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-else>
|
|
<!-- Upcoming Information -->
|
|
<div class="d-flex justify-space-between align-center mb-2">
|
|
<span class="text-body-2 text-medium-emphasis">
|
|
<v-icon size="14" class="mr-1">mdi-calendar</v-icon>
|
|
Due Date
|
|
</span>
|
|
<span class="text-body-2 font-weight-bold text-warning">
|
|
{{ formatDate(member.nextDueDate || member.payment_due_date || '') }}
|
|
</span>
|
|
</div>
|
|
|
|
<div class="d-flex justify-space-between align-center">
|
|
<span class="text-body-2 text-medium-emphasis">
|
|
<v-icon size="14" class="mr-1">mdi-clock</v-icon>
|
|
Days Until Due
|
|
</span>
|
|
<span class="text-body-2 font-weight-bold text-warning">
|
|
{{ member.daysUntilDue || 0 }} days
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Contact Info -->
|
|
<div class="contact-info mb-3">
|
|
<div v-if="member.email" class="d-flex align-center mb-1">
|
|
<v-icon size="14" class="mr-2 text-medium-emphasis">mdi-email</v-icon>
|
|
<span class="text-body-2 text-truncate">{{ member.email }}</span>
|
|
</div>
|
|
<div v-if="member.phone" class="d-flex align-center">
|
|
<v-icon size="14" class="mr-2 text-medium-emphasis">mdi-phone</v-icon>
|
|
<span class="text-body-2">{{ member.FormattedPhone || member.phone }}</span>
|
|
</div>
|
|
</div>
|
|
</v-card-text>
|
|
|
|
<!-- Action Buttons -->
|
|
<v-card-actions class="pa-4 pt-0">
|
|
<v-btn
|
|
color="success"
|
|
variant="elevated"
|
|
size="small"
|
|
:loading="loading"
|
|
@click="showPaymentDateDialog = true"
|
|
block
|
|
>
|
|
<v-icon start size="16">mdi-check-circle</v-icon>
|
|
Mark as Paid
|
|
</v-btn>
|
|
</v-card-actions>
|
|
|
|
<!-- 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>
|
|
|
|
<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="loading"
|
|
@click="confirmMarkAsPaid"
|
|
>
|
|
<v-icon start>mdi-check-circle</v-icon>
|
|
Confirm Payment
|
|
</v-btn>
|
|
</v-card-actions>
|
|
</v-card>
|
|
</v-dialog>
|
|
|
|
<!-- Quick Actions -->
|
|
<v-card-actions class="pa-4 pt-0">
|
|
<v-btn
|
|
variant="text"
|
|
size="small"
|
|
@click="$emit('view-member', member)"
|
|
>
|
|
<v-icon start size="16">mdi-account</v-icon>
|
|
View Details
|
|
</v-btn>
|
|
|
|
<v-spacer />
|
|
|
|
<v-btn
|
|
variant="text"
|
|
size="small"
|
|
:loading="emailLoading"
|
|
:disabled="!member.email"
|
|
@click="sendDuesReminder"
|
|
v-if="member.email"
|
|
>
|
|
<v-icon start size="16">mdi-email</v-icon>
|
|
Email
|
|
</v-btn>
|
|
</v-card-actions>
|
|
</v-card>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import type { Member } from '~/utils/types';
|
|
import ProfileAvatar from '~/components/ProfileAvatar.vue';
|
|
|
|
// Extended member type for dues management
|
|
interface DuesMember {
|
|
Id: string;
|
|
first_name: string;
|
|
last_name: string;
|
|
email: string;
|
|
phone: string;
|
|
nationality?: string;
|
|
member_id?: string;
|
|
FullName?: string;
|
|
FormattedPhone?: string;
|
|
overdueDays?: number;
|
|
overdueReason?: string;
|
|
daysUntilDue?: number;
|
|
nextDueDate?: string;
|
|
membership_date_paid?: string;
|
|
payment_due_date?: string;
|
|
current_year_dues_paid?: string;
|
|
}
|
|
|
|
interface Props {
|
|
member: DuesMember;
|
|
status: 'overdue' | 'upcoming';
|
|
loading?: boolean;
|
|
}
|
|
|
|
interface Emits {
|
|
(e: 'mark-paid', member: Member): void;
|
|
(e: 'view-member', member: DuesMember): void;
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
loading: false
|
|
});
|
|
|
|
const emit = defineEmits<Emits>();
|
|
|
|
// Reactive state for payment date dialog
|
|
const showPaymentDateDialog = ref(false);
|
|
const selectedPaymentDate = ref('');
|
|
const selectedPaymentModel = ref<Date | null>(null);
|
|
|
|
// Reactive state for email sending
|
|
const emailLoading = ref(false);
|
|
|
|
// Initialize with today's date when dialog opens
|
|
watch(showPaymentDateDialog, (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];
|
|
}
|
|
};
|
|
|
|
// Computed properties
|
|
const memberInitials = computed(() => {
|
|
const firstName = props.member.first_name || '';
|
|
const lastName = props.member.last_name || '';
|
|
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase();
|
|
});
|
|
|
|
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;
|
|
});
|
|
|
|
const avatarColor = computed(() => {
|
|
const colors = ['red', 'blue', 'green', 'orange', 'purple', 'teal', 'indigo', 'pink'];
|
|
const idNumber = parseInt(props.member.Id) || 0;
|
|
return colors[idNumber % colors.length];
|
|
});
|
|
|
|
const statusColor = computed(() => {
|
|
return props.status === 'overdue' ? 'error' : 'warning';
|
|
});
|
|
|
|
const statusIcon = computed(() => {
|
|
return props.status === 'overdue' ? 'mdi-alert-circle' : 'mdi-clock-alert';
|
|
});
|
|
|
|
const statusText = computed(() => {
|
|
return props.status === 'overdue' ? 'Overdue' : 'Due Soon';
|
|
});
|
|
|
|
const daysDifference = computed(() => {
|
|
if (!props.member.payment_due_date) return null;
|
|
|
|
const today = new Date();
|
|
const dueDate = new Date(props.member.payment_due_date);
|
|
const diffTime = dueDate.getTime() - today.getTime();
|
|
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
|
|
|
return diffDays;
|
|
});
|
|
|
|
// Methods
|
|
const calculateDisplayOverdueDays = (member: DuesMember): number => {
|
|
// First try to use the pre-calculated overdue days from the API
|
|
if (member.overdueDays !== undefined && member.overdueDays > 0) {
|
|
return member.overdueDays;
|
|
}
|
|
|
|
// Fallback calculation if not provided
|
|
const today = new Date();
|
|
const DAYS_IN_YEAR = 365;
|
|
|
|
// Check if payment is over 1 year old
|
|
if (member.membership_date_paid) {
|
|
try {
|
|
const lastPaidDate = new Date(member.membership_date_paid);
|
|
const oneYearFromPayment = new Date(lastPaidDate);
|
|
oneYearFromPayment.setFullYear(oneYearFromPayment.getFullYear() + 1);
|
|
|
|
if (today > oneYearFromPayment) {
|
|
const daysSincePayment = Math.floor((today.getTime() - lastPaidDate.getTime()) / (1000 * 60 * 60 * 24));
|
|
return Math.max(0, daysSincePayment - DAYS_IN_YEAR);
|
|
}
|
|
} catch {
|
|
// Fall through to due date check
|
|
}
|
|
}
|
|
|
|
// Check if past due date
|
|
if (member.payment_due_date) {
|
|
try {
|
|
const dueDate = new Date(member.payment_due_date);
|
|
if (today > dueDate) {
|
|
return Math.floor((today.getTime() - dueDate.getTime()) / (1000 * 60 * 60 * 24));
|
|
}
|
|
} catch {
|
|
// Invalid date
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
};
|
|
|
|
const formatDate = (dateString: string): string => {
|
|
if (!dateString) return '';
|
|
|
|
try {
|
|
const date = new Date(dateString);
|
|
return date.toLocaleDateString('en-US', {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric'
|
|
});
|
|
} catch {
|
|
return dateString;
|
|
}
|
|
};
|
|
|
|
const cancelPaymentDialog = () => {
|
|
showPaymentDateDialog.value = false;
|
|
selectedPaymentDate.value = '';
|
|
};
|
|
|
|
const confirmMarkAsPaid = async () => {
|
|
if (!selectedPaymentDate.value || isDateInFuture.value) return;
|
|
|
|
try {
|
|
// Call the API with the selected payment date
|
|
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) {
|
|
// Emit the mark-paid event with the updated member data
|
|
emit('mark-paid', 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 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 = props.status === 'overdue' ? '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;
|
|
}
|
|
};
|
|
</script>
|
|
|
|
<style scoped>
|
|
.dues-action-card {
|
|
border-radius: 12px !important;
|
|
transition: all 0.3s ease;
|
|
position: relative;
|
|
height: 100%;
|
|
}
|
|
|
|
.dues-action-card:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important;
|
|
}
|
|
|
|
.dues-action-card--overdue {
|
|
border-left: 4px solid rgb(var(--v-theme-error));
|
|
}
|
|
|
|
.dues-action-card--upcoming {
|
|
border-left: 4px solid rgb(var(--v-theme-warning));
|
|
}
|
|
|
|
.status-badge {
|
|
position: absolute;
|
|
top: 12px;
|
|
right: 12px;
|
|
z-index: 2;
|
|
}
|
|
|
|
.dues-info {
|
|
background: rgba(var(--v-theme-surface-variant), 0.1);
|
|
border-radius: 8px;
|
|
padding: 12px;
|
|
}
|
|
|
|
.contact-info {
|
|
border-radius: 6px;
|
|
padding: 8px;
|
|
background: rgba(var(--v-theme-surface-variant), 0.05);
|
|
}
|
|
|
|
.text-truncate {
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
max-width: 150px;
|
|
}
|
|
|
|
/* 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 responsive */
|
|
@media (max-width: 600px) {
|
|
.dues-action-card {
|
|
margin-bottom: 12px;
|
|
}
|
|
}
|
|
</style>
|