monacousa-portal/components/MemberCard.vue

552 lines
14 KiB
Vue

<template>
<v-card
class="member-card"
:class="{ 'member-card--inactive': !isActive }"
elevation="2"
@click="$emit('view', member)"
>
<!-- 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)" class="member-action-buttons">
<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">
<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">
<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';
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 the same logic as dues-status API
*/
const isPaymentOverOneYear = computed(() => {
if (!props.member.membership_date_paid) return false;
try {
const lastPaidDate = new Date(props.member.membership_date_paid);
const oneYearFromPayment = new Date(lastPaidDate);
oneYearFromPayment.setFullYear(oneYearFromPayment.getFullYear() + 1);
const today = new Date();
return today > oneYearFromPayment;
} catch {
return false;
}
});
/**
* Check if dues are actually current
* Uses the same logic as dues-status API
*/
const isDuesActuallyCurrent = computed(() => {
const paymentTooOld = isPaymentOverOneYear.value;
const duesCurrentlyPaid = props.member.current_year_dues_paid === 'true';
const gracePeriod = isInGracePeriod.value;
// Member is NOT overdue if they're in grace period OR (dues paid AND payment not too old)
const isOverdue = paymentTooOld || (!duesCurrentlyPaid && !gracePeriod);
return !isOverdue;
});
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;
});
// 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;
}
};
</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;
}
</style>