634 lines
16 KiB
Vue
634 lines
16 KiB
Vue
<template>
|
|
<v-card
|
|
class="member-card"
|
|
:class="{
|
|
'member-card--inactive': !isActive,
|
|
'member-card--overdue': isOverdue,
|
|
'member-card--due-soon': isDuesComingDue
|
|
}"
|
|
elevation="2"
|
|
@click="$emit('view', member)"
|
|
>
|
|
<!-- Status Stripe -->
|
|
<div
|
|
v-if="isOverdue || isDuesComingDue"
|
|
class="status-stripe"
|
|
:class="{
|
|
'status-stripe--overdue': isOverdue,
|
|
'status-stripe--due-soon': isDuesComingDue
|
|
}"
|
|
/>
|
|
<!-- Member Status Badge -->
|
|
<div class="member-status-badge">
|
|
<v-chip
|
|
:color="statusColor"
|
|
size="small"
|
|
variant="flat"
|
|
class="font-weight-bold"
|
|
>
|
|
<v-icon v-if="!isActive" start size="12">mdi-account-off</v-icon>
|
|
<v-icon v-else start size="12">mdi-account-check</v-icon>
|
|
{{ member.membership_status || 'Inactive' }}
|
|
</v-chip>
|
|
</div>
|
|
|
|
<!-- Action Buttons -->
|
|
<div v-if="canEdit || canDelete || (!member.keycloak_id && canCreatePortalAccount) || shouldShowEmailButton" class="member-action-buttons">
|
|
<!-- Email Button for Overdue/Due Soon Members -->
|
|
<v-btn
|
|
v-if="shouldShowEmailButton"
|
|
icon
|
|
size="small"
|
|
variant="text"
|
|
:color="isOverdue ? 'error' : 'warning'"
|
|
:loading="emailLoading"
|
|
@click.stop="sendDuesReminder"
|
|
:title="'Send Dues Reminder to ' + member.FullName"
|
|
>
|
|
<v-icon>mdi-email-alert</v-icon>
|
|
</v-btn>
|
|
|
|
<v-btn
|
|
v-if="canEdit"
|
|
icon
|
|
size="small"
|
|
variant="text"
|
|
@click.stop="$emit('edit', member)"
|
|
:title="'Edit ' + member.FullName"
|
|
>
|
|
<v-icon>mdi-pencil</v-icon>
|
|
</v-btn>
|
|
|
|
<v-btn
|
|
v-if="canDelete"
|
|
icon
|
|
size="small"
|
|
variant="text"
|
|
color="error"
|
|
@click.stop="$emit('delete', member)"
|
|
:title="'Delete ' + member.FullName"
|
|
>
|
|
<v-icon>mdi-delete</v-icon>
|
|
</v-btn>
|
|
|
|
<!-- Create Portal Account Button (Circular) -->
|
|
<v-btn
|
|
v-if="!member.keycloak_id && canCreatePortalAccount"
|
|
icon
|
|
size="small"
|
|
variant="text"
|
|
color="primary"
|
|
:loading="creatingPortalAccount"
|
|
@click.stop="$emit('create-portal-account', member)"
|
|
:title="'Create Portal Account for ' + member.FullName"
|
|
>
|
|
<v-icon>mdi-account-plus</v-icon>
|
|
</v-btn>
|
|
</div>
|
|
|
|
<!-- Card Content -->
|
|
<v-card-text class="pb-4 pt-3">
|
|
<div class="d-flex align-center mb-2">
|
|
<ProfileAvatar
|
|
:member-id="member.member_id"
|
|
:member-name="displayName"
|
|
:first-name="member.first_name"
|
|
:last-name="member.last_name"
|
|
size="medium"
|
|
class="mr-3"
|
|
/>
|
|
|
|
<div class="flex-grow-1">
|
|
<h3 class="text-subtitle-1 font-weight-bold mb-1">
|
|
{{ displayName }}
|
|
</h3>
|
|
<div class="nationality-display">
|
|
<template v-if="nationalitiesArray.length > 0">
|
|
<div class="d-flex align-center flex-wrap">
|
|
<!-- Display all flags together -->
|
|
<div class="flags-container d-flex align-center me-2">
|
|
<CountryFlag
|
|
v-for="nationality in nationalitiesArray"
|
|
:key="nationality"
|
|
:country-code="nationality"
|
|
:show-name="false"
|
|
size="small"
|
|
class="flag-item"
|
|
/>
|
|
</div>
|
|
<!-- Display country names -->
|
|
<div class="country-names">
|
|
<span class="text-caption text-medium-emphasis">
|
|
{{ nationalitiesArray.map(n => getCountryName(n)).join(', ') }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<template v-else>
|
|
<span class="text-caption text-medium-emphasis">
|
|
Unknown
|
|
</span>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Member Info - More Compact -->
|
|
<div class="member-info mb-2">
|
|
<div class="info-row mb-1" v-if="member.email">
|
|
<v-icon size="14" class="mr-2 text-medium-emphasis">mdi-email</v-icon>
|
|
<span class="text-caption">{{ member.email }}</span>
|
|
</div>
|
|
|
|
<div class="info-row mb-1" v-if="member.phone">
|
|
<v-icon size="14" class="mr-2 text-medium-emphasis">mdi-phone</v-icon>
|
|
<span class="text-caption">{{ member.FormattedPhone || member.phone }}</span>
|
|
</div>
|
|
|
|
<div class="info-row mb-1" v-if="member.member_since">
|
|
<v-icon size="14" class="mr-2 text-medium-emphasis">mdi-calendar</v-icon>
|
|
<span class="text-caption">Since {{ formatDate(member.member_since) }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Status Section - Reorganized -->
|
|
<div class="status-section">
|
|
<!-- Primary Status (Dues) -->
|
|
<div class="d-flex align-center justify-space-between mb-2">
|
|
<v-chip
|
|
:color="duesColor"
|
|
:variant="duesVariant"
|
|
size="small"
|
|
class="mr-1"
|
|
>
|
|
<v-icon start size="12">{{ duesIcon }}</v-icon>
|
|
{{ duesText }}
|
|
</v-chip>
|
|
|
|
<!-- Portal Status - Compact -->
|
|
<v-tooltip
|
|
:text="member.keycloak_id ? 'Portal Account Active' : 'No Portal Account'"
|
|
location="top"
|
|
>
|
|
<template #activator="{ props }">
|
|
<v-chip
|
|
v-bind="props"
|
|
:color="member.keycloak_id ? 'success' : 'grey'"
|
|
variant="tonal"
|
|
size="x-small"
|
|
class="ml-1"
|
|
>
|
|
<v-icon size="12">{{ member.keycloak_id ? 'mdi-account-check' : 'mdi-account-off' }}</v-icon>
|
|
</v-chip>
|
|
</template>
|
|
</v-tooltip>
|
|
</div>
|
|
|
|
<!-- Secondary Status (Due Dates) - Only show if relevant -->
|
|
<div v-if="isDuesComingDue || (member.payment_due_date && !isDuesComingDue && isOverdue)" class="d-flex">
|
|
<v-chip
|
|
v-if="isDuesComingDue"
|
|
color="orange"
|
|
variant="flat"
|
|
size="x-small"
|
|
>
|
|
<v-icon start size="10">mdi-clock-alert</v-icon>
|
|
Due {{ formatDate(nextDuesDate) }}
|
|
</v-chip>
|
|
|
|
<v-chip
|
|
v-else-if="member.payment_due_date && !isDuesComingDue && isOverdue"
|
|
color="error"
|
|
variant="flat"
|
|
size="x-small"
|
|
>
|
|
<v-icon start size="10">mdi-calendar-alert</v-icon>
|
|
Overdue
|
|
</v-chip>
|
|
</div>
|
|
</div>
|
|
</v-card-text>
|
|
|
|
<!-- Click overlay for better UX -->
|
|
<div class="member-card-overlay" @click="$emit('view', member)"></div>
|
|
</v-card>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import type { Member } from '~/utils/types';
|
|
import { getCountryName } from '~/utils/countries';
|
|
import {
|
|
isPaymentOverOneYear as checkPaymentOverOneYear,
|
|
isDuesActuallyCurrent as checkDuesActuallyCurrent,
|
|
calculateOverdueDays
|
|
} from '~/utils/dues-calculations';
|
|
|
|
interface Props {
|
|
member: Member;
|
|
canEdit?: boolean;
|
|
canDelete?: boolean;
|
|
canCreatePortalAccount?: boolean;
|
|
creatingPortalAccount?: boolean;
|
|
}
|
|
|
|
interface Emits {
|
|
(e: 'view', member: Member): void;
|
|
(e: 'edit', member: Member): void;
|
|
(e: 'delete', member: Member): void;
|
|
(e: 'create-portal-account', member: Member): void;
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
canEdit: false,
|
|
canDelete: false,
|
|
canCreatePortalAccount: false,
|
|
creatingPortalAccount: false
|
|
});
|
|
|
|
defineEmits<Emits>();
|
|
|
|
// 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 displayName = computed(() => {
|
|
// Try FullName first, then build from first_name + last_name, then fallback
|
|
return props.member.FullName ||
|
|
`${props.member.first_name || ''} ${props.member.last_name || ''}`.trim() ||
|
|
'New Member';
|
|
});
|
|
|
|
const avatarColor = computed(() => {
|
|
// Generate consistent color based on member ID using high-contrast colors
|
|
const colors = ['red', 'blue', 'green', 'orange', 'purple', 'teal', 'indigo', 'pink', 'brown'];
|
|
const idNumber = parseInt(props.member.Id) || 0;
|
|
return colors[idNumber % colors.length];
|
|
});
|
|
|
|
const nationalitiesArray = computed(() => {
|
|
if (!props.member.nationality) return [];
|
|
|
|
// Handle multiple nationalities separated by comma, semicolon, or pipe
|
|
const nationalities = props.member.nationality
|
|
.split(/[,;|]/)
|
|
.map(n => n.trim().toUpperCase())
|
|
.filter(n => n.length > 0);
|
|
|
|
return nationalities;
|
|
});
|
|
|
|
const isActive = computed(() => {
|
|
return props.member.membership_status === 'Active';
|
|
});
|
|
|
|
const statusColor = computed(() => {
|
|
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';
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Check if a member is in their grace period
|
|
* Uses the same logic as dues-status API
|
|
*/
|
|
const isInGracePeriod = computed(() => {
|
|
if (!props.member.payment_due_date) return false;
|
|
|
|
try {
|
|
const dueDate = new Date(props.member.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
|
|
* Uses standardized dues calculation function
|
|
*/
|
|
const isPaymentOverOneYear = computed(() => {
|
|
return checkPaymentOverOneYear(props.member);
|
|
});
|
|
|
|
/**
|
|
* Check if dues are actually current
|
|
* Uses standardized dues calculation function
|
|
*/
|
|
const isDuesActuallyCurrent = computed(() => {
|
|
return checkDuesActuallyCurrent(props.member);
|
|
});
|
|
|
|
const duesColor = computed(() => {
|
|
if (isDuesActuallyCurrent.value) return 'success';
|
|
if (isInGracePeriod.value) return 'warning';
|
|
return 'error';
|
|
});
|
|
|
|
const duesVariant = computed(() => {
|
|
if (isDuesActuallyCurrent.value) return 'tonal';
|
|
if (isInGracePeriod.value) return 'tonal';
|
|
return 'flat';
|
|
});
|
|
|
|
const duesIcon = computed(() => {
|
|
if (isDuesActuallyCurrent.value) return 'mdi-check-circle';
|
|
if (isInGracePeriod.value) return 'mdi-clock-alert';
|
|
return 'mdi-alert-circle';
|
|
});
|
|
|
|
const duesText = computed(() => {
|
|
if (isDuesActuallyCurrent.value) return 'Dues Paid';
|
|
if (isInGracePeriod.value) return 'Grace Period';
|
|
return 'Dues Outstanding';
|
|
});
|
|
|
|
const isOverdue = computed(() => {
|
|
// If dues are current, not overdue
|
|
if (isDuesActuallyCurrent.value) return false;
|
|
|
|
// If in grace period, not yet overdue
|
|
if (isInGracePeriod.value) return false;
|
|
|
|
// Check if payment_due_date has passed
|
|
if (props.member.payment_due_date) {
|
|
const dueDate = new Date(props.member.payment_due_date);
|
|
const today = new Date();
|
|
return dueDate < today;
|
|
}
|
|
|
|
// If no due date but not paid and not in grace period, consider overdue
|
|
return props.member.current_year_dues_paid !== 'true';
|
|
});
|
|
|
|
// Calculate next dues date (1 year from when they last paid)
|
|
const nextDuesDate = computed(() => {
|
|
// If dues are paid, calculate 1 year from payment date
|
|
if (props.member.current_year_dues_paid === 'true' && props.member.membership_date_paid) {
|
|
const lastPaidDate = new Date(props.member.membership_date_paid);
|
|
const nextDue = new Date(lastPaidDate);
|
|
nextDue.setFullYear(nextDue.getFullYear() + 1);
|
|
return nextDue.toISOString().split('T')[0]; // Return as date string
|
|
}
|
|
|
|
// If not paid but has a due date, use that
|
|
if (props.member.payment_due_date) {
|
|
return props.member.payment_due_date;
|
|
}
|
|
|
|
// Fallback: 1 year from member since date
|
|
if (props.member.member_since) {
|
|
const memberSince = new Date(props.member.member_since);
|
|
const nextDue = new Date(memberSince);
|
|
nextDue.setFullYear(nextDue.getFullYear() + 1);
|
|
return nextDue.toISOString().split('T')[0];
|
|
}
|
|
|
|
return '';
|
|
});
|
|
|
|
// Check if dues are coming due within 2 months
|
|
const isDuesComingDue = computed(() => {
|
|
// Only show warning if dues are currently paid
|
|
if (props.member.current_year_dues_paid !== 'true') return false;
|
|
if (!nextDuesDate.value) return false;
|
|
|
|
const today = new Date();
|
|
const dueDate = new Date(nextDuesDate.value);
|
|
const twoMonthsFromNow = new Date();
|
|
twoMonthsFromNow.setMonth(twoMonthsFromNow.getMonth() + 2);
|
|
|
|
// Show warning if due date is within the next 2 months
|
|
return dueDate <= twoMonthsFromNow && dueDate > today;
|
|
});
|
|
|
|
// Email functionality
|
|
const emailLoading = ref(false);
|
|
|
|
const shouldShowEmailButton = computed(() => {
|
|
// Only show email button if member has email and is overdue or dues coming due
|
|
return !!(props.member.email && (isOverdue.value || isDuesComingDue.value));
|
|
});
|
|
|
|
// Methods
|
|
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 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;
|
|
}
|
|
};
|
|
</script>
|
|
|
|
<style scoped>
|
|
.member-card {
|
|
cursor: pointer;
|
|
border-radius: 12px !important;
|
|
transition: all 0.3s ease;
|
|
position: relative;
|
|
height: 100%;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.member-card:hover {
|
|
transform: translateY(-4px);
|
|
box-shadow: 0 8px 25px rgba(163, 21, 21, 0.15) !important;
|
|
}
|
|
|
|
.member-card--inactive {
|
|
opacity: 0.8;
|
|
}
|
|
|
|
.member-card--inactive .v-card-text {
|
|
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
|
}
|
|
|
|
.member-status-badge {
|
|
position: absolute;
|
|
top: 12px;
|
|
right: 12px;
|
|
z-index: 2;
|
|
}
|
|
|
|
.member-action-buttons {
|
|
position: absolute;
|
|
bottom: 12px;
|
|
right: 12px;
|
|
z-index: 3;
|
|
display: flex;
|
|
gap: 4px;
|
|
}
|
|
|
|
.member-action-buttons .v-btn {
|
|
pointer-events: all;
|
|
background-color: rgba(255, 255, 255, 0.9);
|
|
backdrop-filter: blur(4px);
|
|
}
|
|
|
|
.nationality-display {
|
|
min-height: 20px;
|
|
display: flex;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.flags-container {
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
.flag-item {
|
|
margin-right: 4px;
|
|
}
|
|
|
|
.flag-item:last-child {
|
|
margin-right: 0;
|
|
}
|
|
|
|
.country-names {
|
|
flex: 1;
|
|
}
|
|
|
|
.member-info {
|
|
min-height: 60px;
|
|
}
|
|
|
|
.info-row {
|
|
display: flex;
|
|
align-items: center;
|
|
min-height: 24px;
|
|
}
|
|
|
|
.dues-status {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
}
|
|
|
|
.member-card-overlay {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
z-index: 1;
|
|
pointer-events: none;
|
|
}
|
|
|
|
/* Responsive adjustments */
|
|
@media (max-width: 600px) {
|
|
.member-card {
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.dues-status {
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
}
|
|
|
|
.member-action-buttons {
|
|
bottom: 8px;
|
|
right: 8px;
|
|
}
|
|
}
|
|
|
|
/* Animation for status changes */
|
|
.v-chip {
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
/* Custom scrollbar for long content */
|
|
.member-info::-webkit-scrollbar {
|
|
width: 4px;
|
|
}
|
|
|
|
.member-info::-webkit-scrollbar-track {
|
|
background: transparent;
|
|
}
|
|
|
|
.member-info::-webkit-scrollbar-thumb {
|
|
background-color: rgba(163, 21, 21, 0.3);
|
|
border-radius: 2px;
|
|
}
|
|
|
|
.text-error {
|
|
color: rgb(var(--v-theme-error)) !important;
|
|
}
|
|
|
|
/* Status Stripe Styles */
|
|
.status-stripe {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 4px;
|
|
height: 100%;
|
|
z-index: 2;
|
|
border-radius: 12px 0 0 12px;
|
|
}
|
|
|
|
.status-stripe--overdue {
|
|
background: linear-gradient(180deg, #f44336 0%, #d32f2f 100%);
|
|
box-shadow: 2px 0 8px rgba(244, 67, 54, 0.3);
|
|
}
|
|
|
|
.status-stripe--due-soon {
|
|
background: linear-gradient(180deg, #ff9800 0%, #f57c00 100%);
|
|
box-shadow: 2px 0 8px rgba(255, 152, 0, 0.3);
|
|
}
|
|
|
|
.member-card--overdue {
|
|
border-left: 4px solid #f44336;
|
|
}
|
|
|
|
.member-card--due-soon {
|
|
border-left: 4px solid #ff9800;
|
|
}
|
|
</style>
|