monacousa-portal/components/DuesActionCard.vue

567 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">
<v-avatar
:color="avatarColor"
size="40"
class="mr-3"
>
<span class="text-white font-weight-bold">
{{ memberInitials }}
</span>
</v-avatar>
<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>
<div class="date-picker-wrapper">
<label class="date-picker-label">Payment Date</label>
<VueDatePicker
v-model="selectedPaymentModel"
:timezone="{
timezone: 'Europe/Monaco',
emitTimezone: 'UTC'
}"
:format="'dd/MM/yyyy (Monaco)'"
:max-date="new Date()"
placeholder="Select payment date"
:enable-time-picker="false"
auto-apply
:clearable="false"
:required="true"
@update:model-value="handleDateUpdate"
/>
<div class="text-caption text-medium-emphasis mt-1">
Select the date when the payment was received
</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"
: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"
:href="`mailto:${member.email}?subject=MonacoUSA Membership Dues Reminder`"
target="_blank"
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';
// 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);
// 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
}
};
</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>