Add member management system with NocoDB integration
Build And Push Image / docker (push) Successful in 3m5s Details

- Add member CRUD operations with API endpoints
- Implement member list page with card-based layout
- Add member creation and viewing dialogs
- Support multiple nationalities with country flags
- Include phone number input with international formatting
- Integrate NocoDB as backend database
- Add comprehensive member data types and utilities
This commit is contained in:
Matt 2025-08-07 19:20:29 +02:00
parent c84442433f
commit af99ea48e2
21 changed files with 4794 additions and 139 deletions

View File

@ -0,0 +1,456 @@
<template>
<v-dialog
:model-value="modelValue"
@update:model-value="$emit('update:model-value', $event)"
max-width="900"
persistent
scrollable
>
<v-card>
<v-card-title class="d-flex align-center pa-6 bg-primary">
<v-icon class="mr-3 text-white">mdi-account-plus</v-icon>
<h2 class="text-h5 text-white font-weight-bold flex-grow-1">
Add New Member
</h2>
<v-btn
icon
variant="text"
color="white"
@click="closeDialog"
>
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text class="pa-6">
<v-form ref="formRef" v-model="formValid" @submit.prevent="handleSubmit">
<v-row>
<!-- Personal Information Section -->
<v-col cols="12">
<h3 class="text-h6 mb-4 text-primary">Personal Information</h3>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="form['First Name']"
label="First Name"
variant="outlined"
:rules="[rules.required]"
required
:error="hasFieldError('First Name')"
:error-messages="getFieldError('First Name')"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="form['Last Name']"
label="Last Name"
variant="outlined"
:rules="[rules.required]"
required
:error="hasFieldError('Last Name')"
:error-messages="getFieldError('Last Name')"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="form.Email"
label="Email Address"
type="email"
variant="outlined"
:rules="[rules.required, rules.email]"
required
:error="hasFieldError('Email')"
:error-messages="getFieldError('Email')"
/>
</v-col>
<v-col cols="12" md="6">
<PhoneInputWrapper
v-model="form.Phone"
label="Phone Number"
placeholder="Enter phone number"
:error="hasFieldError('Phone')"
:error-message="getFieldError('Phone')"
@phone-data="handlePhoneData"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="form['Date of Birth']"
label="Date of Birth"
type="date"
variant="outlined"
:error="hasFieldError('Date of Birth')"
:error-messages="getFieldError('Date of Birth')"
/>
</v-col>
<v-col cols="12" md="6">
<MultipleNationalityInput
v-model="form.Nationality"
label="Nationality"
:error="hasFieldError('Nationality')"
:error-message="getFieldError('Nationality')"
:max-nationalities="3"
/>
</v-col>
<v-col cols="12">
<v-textarea
v-model="form.Address"
label="Address"
variant="outlined"
rows="2"
:error="hasFieldError('Address')"
:error-messages="getFieldError('Address')"
/>
</v-col>
<!-- Membership Information Section -->
<v-col cols="12">
<v-divider class="my-4" />
<h3 class="text-h6 mb-4 text-primary">Membership Information</h3>
</v-col>
<v-col cols="12" md="4">
<v-select
v-model="form['Membership Status']"
:items="membershipStatusOptions"
label="Membership Status"
variant="outlined"
:rules="[rules.required]"
required
:error="hasFieldError('Membership Status')"
:error-messages="getFieldError('Membership Status')"
/>
</v-col>
<v-col cols="12" md="4">
<v-text-field
v-model="form['Member Since']"
label="Member Since"
type="date"
variant="outlined"
:error="hasFieldError('Member Since')"
:error-messages="getFieldError('Member Since')"
/>
</v-col>
<v-col cols="12" md="4">
<v-switch
v-model="duesPaid"
label="Current Year Dues Paid"
color="success"
inset
:error="hasFieldError('Current Year Dues Paid')"
:error-messages="getFieldError('Current Year Dues Paid')"
/>
</v-col>
<v-col cols="12" md="6" v-if="duesPaid">
<v-text-field
v-model="form['Membership Date Paid']"
label="Payment Date"
type="date"
variant="outlined"
:error="hasFieldError('Membership Date Paid')"
:error-messages="getFieldError('Membership Date Paid')"
/>
</v-col>
<v-col cols="12" md="6" v-if="!duesPaid">
<v-text-field
v-model="form['Payment Due Date']"
label="Payment Due Date"
type="date"
variant="outlined"
:error="hasFieldError('Payment Due Date')"
:error-messages="getFieldError('Payment Due Date')"
/>
</v-col>
</v-row>
</v-form>
</v-card-text>
<v-card-actions class="pa-6 pt-0">
<v-spacer />
<v-btn
variant="text"
@click="closeDialog"
:disabled="loading"
>
Cancel
</v-btn>
<v-btn
color="primary"
@click="handleSubmit"
:loading="loading"
:disabled="!formValid"
>
<v-icon start>mdi-account-plus</v-icon>
Add Member
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import type { Member } from '~/utils/types';
import { formatBooleanAsString } from '~/server/utils/nocodb';
interface Props {
modelValue: boolean;
}
interface Emits {
(e: 'update:model-value', value: boolean): void;
(e: 'member-created', member: Member): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
// Form state
const formRef = ref();
const formValid = ref(false);
const loading = ref(false);
// Form data
const form = ref({
'First Name': '',
'Last Name': '',
Email: '',
Phone: '',
'Date of Birth': '',
Nationality: '',
Address: '',
'Membership Status': 'Active',
'Member Since': new Date().toISOString().split('T')[0], // Today's date
'Current Year Dues Paid': 'false',
'Membership Date Paid': '',
'Payment Due Date': ''
});
// Additional form state
const duesPaid = ref(false);
const phoneData = ref(null);
// Error handling
const fieldErrors = ref<Record<string, string>>({});
// Watch dues paid switch
watch(duesPaid, (newValue) => {
form.value['Current Year Dues Paid'] = formatBooleanAsString(newValue);
if (newValue) {
form.value['Payment Due Date'] = '';
if (!form.value['Membership Date Paid']) {
form.value['Membership Date Paid'] = new Date().toISOString().split('T')[0];
}
} else {
form.value['Membership Date Paid'] = '';
if (!form.value['Payment Due Date']) {
// Set due date to one year from member since date or today
const memberSince = form.value['Member Since'] || new Date().toISOString().split('T')[0];
const dueDate = new Date(memberSince);
dueDate.setFullYear(dueDate.getFullYear() + 1);
form.value['Payment Due Date'] = dueDate.toISOString().split('T')[0];
}
}
});
// Membership status options
const membershipStatusOptions = [
{ title: 'Active', value: 'Active' },
{ title: 'Inactive', value: 'Inactive' },
{ title: 'Pending', value: 'Pending' },
{ title: 'Expired', value: 'Expired' }
];
// Validation rules
const rules = {
required: (value: any) => {
if (typeof value === 'string') {
return !!value?.trim() || 'This field is required';
}
return !!value || 'This field is required';
},
email: (value: string) => {
const pattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return !value || pattern.test(value) || 'Please enter a valid email address';
}
};
// Error handling methods
const hasFieldError = (fieldName: string) => {
return !!fieldErrors.value[fieldName];
};
const getFieldError = (fieldName: string) => {
return fieldErrors.value[fieldName] || '';
};
const clearFieldErrors = () => {
fieldErrors.value = {};
};
// Phone data handler
const handlePhoneData = (data: any) => {
phoneData.value = data;
};
// Form submission
const handleSubmit = async () => {
if (!formRef.value) return;
const isValid = await formRef.value.validate();
if (!isValid.valid) {
return;
}
loading.value = true;
clearFieldErrors();
try {
// Prepare the data for submission
const memberData = { ...form.value };
// Ensure required fields are not empty
if (!memberData['First Name']?.trim()) {
throw new Error('First Name is required');
}
if (!memberData['Last Name']?.trim()) {
throw new Error('Last Name is required');
}
if (!memberData.Email?.trim()) {
throw new Error('Email is required');
}
console.log('[AddMemberDialog] Submitting member data:', memberData);
const response = await $fetch<{ success: boolean; data: Member; message?: string }>('/api/members', {
method: 'POST',
body: memberData
});
if (response.success && response.data) {
console.log('[AddMemberDialog] Member created successfully:', response.data);
emit('member-created', response.data);
closeDialog();
resetForm();
} else {
throw new Error(response.message || 'Failed to create member');
}
} catch (error: any) {
console.error('[AddMemberDialog] Error creating member:', error);
// Handle validation errors
if (error.data?.fieldErrors) {
fieldErrors.value = error.data.fieldErrors;
} else {
// Show general error
fieldErrors.value = {
general: error.message || 'Failed to create member. Please try again.'
};
}
} finally {
loading.value = false;
}
};
// Dialog management
const closeDialog = () => {
emit('update:model-value', false);
};
const resetForm = () => {
form.value = {
'First Name': '',
'Last Name': '',
Email: '',
Phone: '',
'Date of Birth': '',
Nationality: '',
Address: '',
'Membership Status': 'Active',
'Member Since': new Date().toISOString().split('T')[0],
'Current Year Dues Paid': 'false',
'Membership Date Paid': '',
'Payment Due Date': ''
};
duesPaid.value = false;
phoneData.value = null;
clearFieldErrors();
// Reset form validation
nextTick(() => {
formRef.value?.resetValidation();
});
};
// Watch for dialog open/close
watch(() => props.modelValue, (newValue) => {
if (newValue) {
// Dialog opened - reset form
resetForm();
}
});
</script>
<style scoped>
.bg-primary {
background: linear-gradient(135deg, #a31515 0%, #d32f2f 100%) !important;
}
.text-primary {
color: #a31515 !important;
}
.v-card {
border-radius: 12px !important;
}
/* Form section spacing */
.v-card-text .v-row .v-col:first-child h3 {
border-bottom: 2px solid rgba(var(--v-theme-primary), 0.12);
padding-bottom: 8px;
}
/* Error message styling */
.field-error {
color: rgb(var(--v-theme-error));
font-size: 0.75rem;
margin-top: 4px;
}
/* Switch styling */
.v-switch {
flex: none;
}
/* Responsive adjustments */
@media (max-width: 960px) {
.v-dialog {
margin: 16px;
}
}
@media (max-width: 600px) {
.v-card-title {
padding: 16px !important;
}
.v-card-text {
padding: 16px !important;
}
.v-card-actions {
padding: 16px !important;
padding-top: 0 !important;
}
}
</style>

View File

@ -0,0 +1,92 @@
<template>
<span class="country-flag" :class="{ 'country-flag--small': size === 'small' }">
<span
class="fi"
:class="flagClasses"
:style="flagStyle"
:title="getCountryName(countryCode)"
></span>
<span v-if="showName && countryCode" class="country-name">
{{ getCountryName(countryCode) }}
</span>
</span>
</template>
<script setup lang="ts">
import { getCountryName } from '~/utils/countries';
interface Props {
countryCode?: string;
showName?: boolean;
size?: 'small' | 'medium' | 'large';
square?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
countryCode: '',
showName: true,
size: 'medium',
square: false
});
const flagClasses = computed(() => {
const classes = [];
if (props.countryCode) {
classes.push(`fi-${props.countryCode.toLowerCase()}`);
}
if (props.square) {
classes.push('fis');
}
return classes;
});
const flagStyle = computed(() => {
const sizeMap = {
small: '1rem',
medium: '1.5rem',
large: '2rem'
};
return {
width: sizeMap[props.size],
height: props.square ? sizeMap[props.size] : `calc(${sizeMap[props.size]} * 0.75)`, // 4:3 aspect ratio
display: 'inline-block',
borderRadius: '2px',
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat'
};
});
</script>
<style scoped>
.country-flag {
display: inline-flex;
align-items: center;
gap: 0.5rem;
vertical-align: middle;
}
.country-flag--small {
gap: 0.25rem;
}
.country-name {
font-size: 0.875rem;
color: inherit;
white-space: nowrap;
}
.country-flag--small .country-name {
font-size: 0.75rem;
}
/* Ensure proper flag display */
.fi {
flex-shrink: 0;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
</style>

317
components/MemberCard.vue Normal file
View File

@ -0,0 +1,317 @@
<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"
>
{{ member['Membership Status'] }}
</v-chip>
</div>
<!-- Card Header -->
<v-card-text class="pb-2">
<div class="d-flex align-center mb-3">
<v-avatar
:color="avatarColor"
size="48"
class="mr-3"
>
<span class="text-white font-weight-bold text-h6">
{{ memberInitials }}
</span>
</v-avatar>
<div class="flex-grow-1">
<h3 class="text-h6 font-weight-bold mb-1">
{{ member.FullName || `${member['First Name']} ${member['Last Name']}` }}
</h3>
<div class="d-flex align-center">
<CountryFlag
v-if="member.Nationality"
:country-code="member.Nationality"
:show-name="false"
size="small"
class="mr-2"
/>
<span class="text-body-2 text-medium-emphasis">
{{ getCountryName(member.Nationality) || 'Unknown' }}
</span>
</div>
</div>
</div>
<!-- Member Info -->
<div class="member-info">
<div class="info-row mb-2">
<v-icon size="16" class="mr-2 text-medium-emphasis">mdi-email</v-icon>
<span class="text-body-2">{{ member.Email || 'No email' }}</span>
</div>
<div class="info-row mb-2" v-if="member.Phone">
<v-icon size="16" class="mr-2 text-medium-emphasis">mdi-phone</v-icon>
<span class="text-body-2">{{ member.FormattedPhone || member.Phone }}</span>
</div>
<div class="info-row mb-2" v-if="member['Member Since']">
<v-icon size="16" class="mr-2 text-medium-emphasis">mdi-calendar</v-icon>
<span class="text-body-2">Member since {{ formatDate(member['Member Since']) }}</span>
</div>
</div>
<!-- Dues Status -->
<div class="dues-status mt-3">
<v-chip
:color="duesColor"
:variant="duesVariant"
size="small"
class="mr-2"
>
<v-icon start size="14">{{ duesIcon }}</v-icon>
{{ duesText }}
</v-chip>
<v-chip
v-if="member['Payment Due Date']"
color="warning"
variant="tonal"
size="small"
:class="{ 'text-error': isOverdue }"
>
<v-icon start size="14">mdi-calendar-alert</v-icon>
{{ isOverdue ? 'Overdue' : `Due ${formatDate(member['Payment Due Date'])}` }}
</v-chip>
</div>
</v-card-text>
<!-- Card Actions -->
<v-card-actions v-if="canEdit || canDelete" class="pt-0">
<v-spacer />
<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>
</v-card-actions>
<!-- 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;
}
interface Emits {
(e: 'view', member: Member): void;
(e: 'edit', member: Member): void;
(e: 'delete', member: Member): void;
}
const props = withDefaults(defineProps<Props>(), {
canEdit: false,
canDelete: 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 avatarColor = computed(() => {
// Generate consistent color based on member ID
const colors = ['primary', 'secondary', 'accent', 'info', 'warning', 'success'];
const idNumber = parseInt(props.member.Id) || 0;
return colors[idNumber % colors.length];
});
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';
}
});
const duesColor = computed(() => {
return props.member['Current Year Dues Paid'] === 'true' ? 'success' : 'error';
});
const duesVariant = computed(() => {
return props.member['Current Year Dues Paid'] === 'true' ? 'tonal' : 'flat';
});
const duesIcon = computed(() => {
return props.member['Current Year Dues Paid'] === 'true' ? 'mdi-check-circle' : 'mdi-alert-circle';
});
const duesText = computed(() => {
return props.member['Current Year Dues Paid'] === 'true' ? 'Dues Paid' : 'Dues Outstanding';
});
const isOverdue = computed(() => {
if (!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';
});
// 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%;
}
.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-info {
min-height: 80px;
}
.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;
}
.v-card-actions {
position: relative;
z-index: 3;
}
.v-card-actions .v-btn {
pointer-events: all;
}
/* Responsive adjustments */
@media (max-width: 600px) {
.member-card {
margin-bottom: 16px;
}
.dues-status {
flex-direction: column;
align-items: flex-start;
}
}
/* 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>

View File

@ -0,0 +1,299 @@
<template>
<div class="multiple-nationality-input">
<v-label v-if="label" class="v-label mb-2">{{ label }}</v-label>
<div class="nationality-list">
<div
v-for="(nationality, index) in nationalities"
:key="`nationality-${index}`"
class="nationality-item d-flex align-center gap-2 mb-2"
>
<v-select
v-model="nationalities[index]"
:items="countryOptions"
:label="`Nationality ${index + 1}`"
variant="outlined"
density="compact"
:error="hasError"
:error-messages="errorMessage"
@update:model-value="updateNationalities"
>
<template #selection="{ item }">
<div class="d-flex align-center gap-2">
<CountryFlag
:country-code="item.value"
:show-name="false"
size="small"
/>
<span>{{ item.title }}</span>
</div>
</template>
<template #item="{ props: itemProps, item }">
<v-list-item v-bind="itemProps">
<template #prepend>
<CountryFlag
:country-code="item.raw.code"
:show-name="false"
size="small"
/>
</template>
<v-list-item-title>{{ item.raw.name }}</v-list-item-title>
</v-list-item>
</template>
</v-select>
<v-btn
v-if="nationalities.length > 1"
icon="mdi-delete"
size="small"
variant="text"
color="error"
@click="removeNationality(index)"
:title="`Remove ${getCountryName(nationality) || 'nationality'}`"
/>
</div>
</div>
<div class="nationality-actions mt-2">
<v-btn
variant="outlined"
color="primary"
size="small"
prepend-icon="mdi-plus"
@click="addNationality"
:disabled="disabled || nationalities.length >= maxNationalities"
>
Add Nationality
</v-btn>
<span v-if="nationalities.length >= maxNationalities" class="text-caption text-medium-emphasis ml-2">
Maximum {{ maxNationalities }} nationalities allowed
</span>
</div>
<!-- Preview of selected nationalities -->
<div v-if="nationalities.length > 0 && !hasEmptyNationality" class="nationality-preview mt-3">
<v-label class="text-caption mb-1">Selected Nationalities:</v-label>
<div class="d-flex flex-wrap gap-1">
<v-chip
v-for="nationality in validNationalities"
:key="nationality"
size="small"
variant="tonal"
color="primary"
>
<CountryFlag
:country-code="nationality"
:show-name="false"
size="small"
class="mr-1"
/>
{{ getCountryName(nationality) }}
</v-chip>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { getAllCountries, getCountryName } from '~/utils/countries';
interface Props {
modelValue?: string; // Comma-separated string like "FR,MC,US"
label?: string;
error?: boolean;
errorMessage?: string;
disabled?: boolean;
maxNationalities?: number;
required?: boolean;
}
interface Emits {
(e: 'update:modelValue', value: string): void;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: '',
maxNationalities: 5,
error: false,
disabled: false,
required: false
});
const emit = defineEmits<Emits>();
// Parse initial nationalities from comma-separated string
const parseNationalities = (value: string): string[] => {
if (!value || value.trim() === '') return [''];
return value.split(',').map(n => n.trim()).filter(n => n.length > 0);
};
// Reactive nationalities array
const nationalities = ref<string[]>(parseNationalities(props.modelValue));
// Ensure there's always at least one empty nationality field
if (nationalities.value.length === 0) {
nationalities.value = [''];
}
// Watch for external model changes
watch(() => props.modelValue, (newValue) => {
const newNationalities = parseNationalities(newValue || '');
if (newNationalities.length === 0) newNationalities.push('');
// Only update if different to prevent loops
const current = nationalities.value.filter(n => n).join(',');
const incoming = newNationalities.filter(n => n).join(',');
if (current !== incoming) {
nationalities.value = newNationalities;
}
});
// Country options for dropdowns
const countryOptions = computed(() => {
const countries = getAllCountries();
return countries.map(country => ({
title: country.name,
value: country.code,
code: country.code,
name: country.name
}));
});
// Computed properties
const validNationalities = computed(() => {
return nationalities.value.filter(n => n && n.trim().length > 0);
});
const hasEmptyNationality = computed(() => {
return nationalities.value.some(n => !n || n.trim() === '');
});
const hasError = computed(() => {
return props.error || !!props.errorMessage;
});
// Methods
const addNationality = () => {
if (nationalities.value.length < props.maxNationalities) {
nationalities.value.push('');
}
};
const removeNationality = (index: number) => {
if (nationalities.value.length > 1) {
nationalities.value.splice(index, 1);
updateNationalities();
}
};
const updateNationalities = () => {
// Remove duplicates and empty values for the model
const uniqueValid = [...new Set(validNationalities.value)];
const result = uniqueValid.join(',');
emit('update:modelValue', result);
};
// Watch nationalities array for changes
watch(nationalities, () => {
updateNationalities();
}, { deep: true });
// Initialize the model value on mount if needed
onMounted(() => {
if (!props.modelValue && validNationalities.value.length > 0) {
updateNationalities();
}
});
</script>
<style scoped>
.multiple-nationality-input {
width: 100%;
}
.nationality-item {
position: relative;
}
.nationality-item .v-select {
flex: 1;
}
.nationality-actions {
display: flex;
align-items: center;
gap: 8px;
}
.nationality-preview {
padding: 12px;
background: rgba(var(--v-theme-surface-variant), 0.1);
border-radius: 8px;
border-left: 4px solid rgb(var(--v-theme-primary));
}
.nationality-preview .v-chip {
margin: 2px;
}
/* Animation for adding/removing items */
.nationality-item {
transition: all 0.3s ease;
}
.nationality-item.v-enter-active,
.nationality-item.v-leave-active {
transition: all 0.3s ease;
}
.nationality-item.v-enter-from,
.nationality-item.v-leave-to {
opacity: 0;
transform: translateY(-10px);
}
/* Error styling */
.error-message {
color: rgb(var(--v-theme-error));
font-size: 0.75rem;
margin-top: 4px;
}
/* Focus and hover states */
.nationality-item .v-btn:hover {
background-color: rgba(var(--v-theme-error), 0.08);
}
/* Responsive adjustments */
@media (max-width: 600px) {
.nationality-item {
flex-direction: column;
align-items: stretch;
gap: 8px;
}
.nationality-item .v-btn {
align-self: flex-end;
width: fit-content;
}
}
/* Priority countries styling in dropdowns */
:deep(.v-list-item[data-country="MC"]) {
background-color: rgba(var(--v-theme-primary), 0.04);
font-weight: 500;
}
:deep(.v-list-item[data-country="FR"]) {
background-color: rgba(var(--v-theme-primary), 0.04);
font-weight: 500;
}
:deep(.v-list-item[data-country="US"]) {
background-color: rgba(var(--v-theme-primary), 0.02);
}
</style>

View File

@ -0,0 +1,226 @@
<template>
<div class="phone-input-wrapper">
<v-label v-if="label" class="v-label mb-2">{{ label }}</v-label>
<div class="phone-input-container" :class="{ 'phone-input-container--error': hasError }">
<PhoneInput
v-model="phoneValue"
@update="handlePhoneUpdate"
:preferred-countries="['MC', 'FR', 'US', 'IT', 'CH', 'GB']"
:translations="{
phoneInput: {
placeholder: placeholder || 'Phone number'
}
}"
country-locale="en-US"
:auto-format="true"
:no-formatting-as-you-type="false"
class="custom-phone-input"
/>
</div>
<div v-if="errorMessage" class="error-message mt-1">
<span class="text-error text-caption">{{ errorMessage }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import PhoneInput from 'base-vue-phone-input';
interface Props {
modelValue?: string;
label?: string;
placeholder?: string;
error?: boolean;
errorMessage?: string;
required?: boolean;
disabled?: boolean;
}
interface Emits {
(e: 'update:modelValue', value: string): void;
(e: 'phone-data', data: any): void;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: '',
placeholder: 'Phone number',
error: false,
required: false,
disabled: false
});
const emit = defineEmits<Emits>();
// Internal phone value
const phoneValue = ref(props.modelValue || '');
const phoneData = ref(null);
// Watch for external changes
watch(() => props.modelValue, (newValue) => {
if (newValue !== phoneValue.value) {
phoneValue.value = newValue || '';
}
});
// Handle phone input updates
const handlePhoneUpdate = (data: any) => {
phoneData.value = data;
// Emit the formatted international number or the raw input
const formattedPhone = data?.formatInternational || data?.e164 || phoneValue.value;
emit('update:modelValue', formattedPhone);
emit('phone-data', data);
};
// Watch phoneValue changes to emit updates
watch(phoneValue, (newValue) => {
if (!phoneData.value) {
// If no phone data yet, just emit the raw value
emit('update:modelValue', newValue);
}
});
const hasError = computed(() => {
return props.error || !!props.errorMessage;
});
</script>
<style scoped>
.phone-input-wrapper {
width: 100%;
}
.phone-input-container {
border: 2px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 4px;
transition: border-color 0.2s ease-in-out;
background: rgb(var(--v-theme-surface));
}
.phone-input-container:hover {
border-color: rgba(var(--v-theme-on-surface), 0.87);
}
.phone-input-container:focus-within {
border-color: rgb(var(--v-theme-primary));
border-width: 2px;
}
.phone-input-container--error {
border-color: rgb(var(--v-theme-error)) !important;
}
/* Style the phone input to match Vuetify */
.phone-input-container :deep(.phone-input) {
border: none !important;
outline: none !important;
background: transparent !important;
font-family: 'Roboto', sans-serif;
font-size: 16px;
padding: 12px 16px;
width: 100%;
color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
}
.phone-input-container :deep(.phone-input input) {
border: none !important;
outline: none !important;
background: transparent !important;
padding: 0 !important;
margin: 0 !important;
font-family: inherit;
font-size: inherit;
color: inherit;
width: 100%;
}
/* Style the country selector */
.phone-input-container :deep(.country-selector) {
border: none !important;
background: transparent !important;
padding: 0 8px 0 0;
margin-right: 8px;
font-size: 16px;
}
.phone-input-container :deep(.country-selector .selected-country) {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
padding: 4px;
border-radius: 4px;
transition: background-color 0.2s ease;
}
.phone-input-container :deep(.country-selector .selected-country:hover) {
background-color: rgba(var(--v-theme-on-surface), 0.08);
}
/* Style the dropdown */
.phone-input-container :deep(.country-list) {
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
border-radius: 4px;
background: rgb(var(--v-theme-surface));
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
max-height: 200px;
overflow-y: auto;
z-index: 1000;
}
.phone-input-container :deep(.country-list .country-option) {
padding: 8px 12px;
cursor: pointer;
transition: background-color 0.2s ease;
border-bottom: 1px solid rgba(var(--v-border-color), 0.12);
}
.phone-input-container :deep(.country-list .country-option:hover) {
background-color: rgba(var(--v-theme-primary), 0.08);
}
.phone-input-container :deep(.country-list .country-option:last-child) {
border-bottom: none;
}
/* Error styling */
.error-message {
min-height: 20px;
}
.text-error {
color: rgb(var(--v-theme-error)) !important;
}
/* Monaco/France flag priority styling */
.phone-input-container :deep(.country-option[data-country-code="MC"]) {
background-color: rgba(var(--v-theme-primary), 0.04);
font-weight: 500;
}
.phone-input-container :deep(.country-option[data-country-code="FR"]) {
background-color: rgba(var(--v-theme-primary), 0.04);
font-weight: 500;
}
/* Dark theme support */
@media (prefers-color-scheme: dark) {
.phone-input-container {
background: rgb(var(--v-theme-surface));
border-color: rgba(var(--v-theme-outline), 0.38);
}
.phone-input-container :deep(.country-list) {
background: rgb(var(--v-theme-surface));
border-color: rgba(var(--v-theme-outline), 0.38);
}
}
/* Responsive adjustments */
@media (max-width: 600px) {
.phone-input-container :deep(.phone-input) {
font-size: 16px; /* Prevent zoom on iOS */
}
}
</style>

View File

@ -0,0 +1,324 @@
<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">
<v-avatar
:color="avatarColor"
size="48"
class="mr-4"
>
<span class="text-white font-weight-bold text-h6">
{{ memberInitials }}
</span>
</v-avatar>
<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'] ? '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">
<v-spacer />
<v-btn
variant="text"
@click="$emit('update:model-value', false)"
>
Close
</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;
}
const props = withDefaults(defineProps<Props>(), {
member: null
});
defineEmits<Emits>();
// 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';
});
// Methods
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;
}
};
</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>

File diff suppressed because it is too large Load Diff

View File

@ -68,6 +68,9 @@ export default defineNuxtConfig({
} }
] ]
], ],
css: [
'flag-icons/css/flag-icons.min.css'
],
app: { app: {
head: { head: {
titleTemplate: "%s • MonacoUSA Portal", titleTemplate: "%s • MonacoUSA Portal",

25
package-lock.json generated
View File

@ -9,7 +9,9 @@
"dependencies": { "dependencies": {
"@nuxt/ui": "^3.2.0", "@nuxt/ui": "^3.2.0",
"@vite-pwa/nuxt": "^0.10.8", "@vite-pwa/nuxt": "^0.10.8",
"base-vue-phone-input": "^0.1.13",
"cookie": "^0.6.0", "cookie": "^0.6.0",
"flag-icons": "^7.5.0",
"formidable": "^3.5.4", "formidable": "^3.5.4",
"mime-types": "^3.0.1", "mime-types": "^3.0.1",
"minio": "^8.0.5", "minio": "^8.0.5",
@ -7307,6 +7309,17 @@
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true "optional": true
}, },
"node_modules/base-vue-phone-input": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/base-vue-phone-input/-/base-vue-phone-input-0.1.13.tgz",
"integrity": "sha512-e3CH2cI/ddnAAv4+cBBiCmU/VVAcfhWjzBGlJMpte1d2xE/ASmENO72zwulHikLkywQ45lYBWkBQwSxMPYfUXA==",
"dependencies": {
"libphonenumber-js": "^1.11.7"
},
"peerDependencies": {
"vue": "^3.4.37"
}
},
"node_modules/base64-js": { "node_modules/base64-js": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@ -9705,6 +9718,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/flag-icons": {
"version": "7.5.0",
"resolved": "https://registry.npmjs.org/flag-icons/-/flag-icons-7.5.0.tgz",
"integrity": "sha512-kd+MNXviFIg5hijH766tt+3x76ele1AXlo4zDdCxIvqWZhKt4T83bOtxUOOMlTx/EcFdUMH5yvQgYlFh1EqqFg==",
"license": "MIT"
},
"node_modules/fn.name": { "node_modules/fn.name": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz",
@ -11944,6 +11963,12 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/libphonenumber-js": {
"version": "1.12.10",
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.10.tgz",
"integrity": "sha512-E91vHJD61jekHHR/RF/E83T/CMoaLXT7cwYA75T4gim4FZjnM6hbJjVIGg7chqlSqRsSvQ3izGmOjHy1SQzcGQ==",
"license": "MIT"
},
"node_modules/lightningcss": { "node_modules/lightningcss": {
"version": "1.30.1", "version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz",

View File

@ -12,7 +12,9 @@
"dependencies": { "dependencies": {
"@nuxt/ui": "^3.2.0", "@nuxt/ui": "^3.2.0",
"@vite-pwa/nuxt": "^0.10.8", "@vite-pwa/nuxt": "^0.10.8",
"base-vue-phone-input": "^0.1.13",
"cookie": "^0.6.0", "cookie": "^0.6.0",
"flag-icons": "^7.5.0",
"formidable": "^3.5.4", "formidable": "^3.5.4",
"mime-types": "^3.0.1", "mime-types": "^3.0.1",
"minio": "^8.0.5", "minio": "^8.0.5",

View File

@ -4,8 +4,8 @@
<v-row> <v-row>
<v-col cols="12"> <v-col cols="12">
<h1 class="text-h4 font-weight-bold mb-4"> <h1 class="text-h4 font-weight-bold mb-4">
<v-icon left>mdi-shield-crown</v-icon> <v-icon left>mdi-account</v-icon>
Administration Welcome Back, {{ firstName }}
</h1> </h1>
<p class="text-body-1 mb-6"> <p class="text-body-1 mb-6">
Manage users and portal settings for the MonacoUSA Portal. Manage users and portal settings for the MonacoUSA Portal.
@ -99,100 +99,7 @@
</v-col> </v-col>
</v-row> </v-row>
<!-- Quick Actions -->
<v-row class="mb-6">
<v-col cols="12">
<v-card elevation="2">
<v-card-title>
<v-icon left>mdi-lightning-bolt</v-icon>
Quick Actions
</v-card-title>
<v-card-text>
<v-row>
<v-col cols="6" md="3">
<v-btn
color="success"
variant="outlined"
block
@click="createUser"
>
<v-icon start>mdi-account-plus</v-icon>
Add User
</v-btn>
</v-col>
<v-col cols="6" md="3">
<v-btn
color="info"
variant="outlined"
block
@click="generateReport"
>
<v-icon start>mdi-file-chart</v-icon>
User Report
</v-btn>
</v-col>
<v-col cols="6" md="3">
<v-btn
color="warning"
variant="outlined"
block
@click="manageRoles"
>
<v-icon start>mdi-shield-account</v-icon>
Manage Roles
</v-btn>
</v-col>
<v-col cols="6" md="3">
<v-btn
color="error"
variant="outlined"
block
@click="systemMaintenance"
>
<v-icon start>mdi-wrench</v-icon>
Maintenance
</v-btn>
</v-col>
</v-row>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- Recent Activity -->
<v-row>
<v-col cols="12">
<v-card elevation="2">
<v-card-title>
<v-icon left>mdi-history</v-icon>
Recent Admin Activity
</v-card-title>
<v-card-text>
<v-list>
<v-list-item
v-for="activity in recentActivity"
:key="activity.id"
class="px-0"
>
<template #prepend>
<v-icon :color="activity.color" class="mr-3">{{ activity.icon }}</v-icon>
</template>
<v-list-item-title>{{ activity.title }}</v-list-item-title>
<v-list-item-subtitle>{{ activity.description }}</v-list-item-subtitle>
<template #append>
<small class="text-medium-emphasis">{{ activity.time }}</small>
</template>
</v-list-item>
</v-list>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container> </v-container>
</div> </div>
</template> </template>
@ -265,8 +172,7 @@ const loadStats = async () => {
// Action methods (placeholders for now) // Action methods (placeholders for now)
const manageUsers = () => { const manageUsers = () => {
console.log('Navigate to user management'); window.open('https://auth.monacousa.org', '_blank');
// TODO: Implement user management navigation
}; };
const viewAuditLogs = () => { const viewAuditLogs = () => {

View File

@ -0,0 +1,466 @@
<template>
<v-container fluid>
<!-- Header -->
<v-row class="mb-4">
<v-col>
<h1 class="text-h4 font-weight-bold mb-4">
<v-icon left>mdi-account-multiple</v-icon>
Welcome Back, {{ firstName }}
</h1>
<p class="text-body-1 mb-4">
Manage MonacoUSA association members and their information.
</p>
</v-col>
</v-row>
<!-- Search and Filter Controls -->
<v-row class="mb-4">
<v-col cols="12" md="4">
<v-text-field
v-model="searchTerm"
label="Search members..."
prepend-inner-icon="mdi-magnify"
variant="outlined"
clearable
@input="debouncedSearch"
/>
</v-col>
<v-col cols="12" md="3">
<v-select
v-model="nationalityFilter"
:items="nationalityOptions"
label="Nationality"
variant="outlined"
clearable
@update:model-value="filterMembers"
>
<template #selection="{ item }">
<CountryFlag :country-code="item.raw.code" :show-name="true" size="small" />
</template>
<template #item="{ props, item }">
<v-list-item v-bind="props">
<template #prepend>
<CountryFlag :country-code="item.raw.code" :show-name="false" size="small" />
</template>
<v-list-item-title>{{ item.raw.name }}</v-list-item-title>
</v-list-item>
</template>
</v-select>
</v-col>
<v-col cols="12" md="3">
<v-select
v-model="statusFilter"
:items="statusOptions"
label="Membership Status"
variant="outlined"
clearable
@update:model-value="filterMembers"
/>
</v-col>
<v-col cols="12" md="2">
<v-btn
color="primary"
block
size="large"
@click="showAddDialog = true"
:disabled="!canCreateMembers"
>
<v-icon start>mdi-plus</v-icon>
Add Member
</v-btn>
</v-col>
</v-row>
<!-- Member Statistics -->
<v-row class="mb-6">
<v-col cols="12" md="3">
<v-card elevation="2">
<v-card-text>
<div class="text-h6 text-primary font-weight-bold">{{ totalMembers }}</div>
<div class="text-body-2 text-medium-emphasis">Total Members</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="3">
<v-card elevation="2">
<v-card-text>
<div class="text-h6 text-success font-weight-bold">{{ activeMembers }}</div>
<div class="text-body-2 text-medium-emphasis">Active Members</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="3">
<v-card elevation="2">
<v-card-text>
<div class="text-h6 text-success font-weight-bold">{{ paidDuesMembers }}</div>
<div class="text-body-2 text-medium-emphasis">Paid Dues</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="3">
<v-card elevation="2">
<v-card-text>
<div class="text-h6 font-weight-bold">{{ uniqueNationalities }}</div>
<div class="text-body-2 text-medium-emphasis">Countries</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- Loading State -->
<v-row v-if="loading" justify="center" class="my-12">
<v-col cols="auto" class="text-center">
<v-progress-circular indeterminate color="primary" size="64" />
<p class="mt-4 text-h6">Loading members...</p>
</v-col>
</v-row>
<!-- Error State -->
<v-alert
v-else-if="error"
type="error"
variant="tonal"
class="mb-4"
closable
@click:close="error = ''"
>
<template #title>Failed to load members</template>
{{ error }}
<template #append>
<v-btn color="error" variant="text" @click="loadMembers">
Try Again
</v-btn>
</template>
</v-alert>
<!-- Members Grid -->
<v-row v-else>
<v-col
v-for="member in filteredMembers"
:key="member.Id"
cols="12"
sm="6"
md="4"
lg="3"
>
<MemberCard
:member="member"
@edit="editMember"
@delete="confirmDeleteMember"
@view="viewMember"
:can-edit="canEditMembers"
:can-delete="canDeleteMembers"
/>
</v-col>
<!-- No Results State -->
<v-col v-if="filteredMembers.length === 0 && !loading && !error" cols="12" class="text-center">
<v-card elevation="0" class="pa-8">
<v-icon size="64" color="grey-lighten-1" class="mb-4">mdi-account-search</v-icon>
<h3 class="text-h5 mb-2">No members found</h3>
<p class="text-body-1 mb-4">
{{ searchTerm || nationalityFilter || statusFilter
? 'Try adjusting your filters to find members.'
: 'No members have been added yet.' }}
</p>
<v-btn
v-if="canCreateMembers && !searchTerm && !nationalityFilter && !statusFilter"
color="primary"
@click="showAddDialog = true"
>
<v-icon start>mdi-plus</v-icon>
Add First Member
</v-btn>
</v-card>
</v-col>
</v-row>
<!-- Add Member Dialog -->
<AddMemberDialog
v-model="showAddDialog"
@member-created="handleMemberCreated"
/>
<!-- Edit Member Dialog -->
<EditMemberDialog
v-model="showEditDialog"
:member="selectedMember"
@member-updated="handleMemberUpdated"
/>
<!-- View Member Dialog -->
<ViewMemberDialog
v-model="showViewDialog"
:member="selectedMember"
/>
<!-- Delete Confirmation Dialog -->
<v-dialog v-model="showDeleteDialog" max-width="400">
<v-card>
<v-card-title class="text-h6">
<v-icon color="error" class="mr-2">mdi-delete-alert</v-icon>
Confirm Delete
</v-card-title>
<v-card-text>
Are you sure you want to delete <strong>{{ selectedMember?.FullName }}</strong>?
<br><br>
<v-alert type="warning" variant="tonal" class="mt-2">
This action cannot be undone.
</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn @click="showDeleteDialog = false" variant="text">
Cancel
</v-btn>
<v-btn
color="error"
@click="deleteMember"
:loading="deleteLoading"
>
<v-icon start>mdi-delete</v-icon>
Delete
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Success Snackbar -->
<v-snackbar
v-model="showSuccess"
:timeout="4000"
color="success"
location="top"
>
{{ successMessage }}
<template #actions>
<v-btn variant="text" @click="showSuccess = false">
Close
</v-btn>
</template>
</v-snackbar>
</v-container>
</template>
<script setup lang="ts">
import type { Member, MembershipStatus } from '~/utils/types';
import { getAllCountries, searchCountries } from '~/utils/countries';
definePageMeta({
layout: 'dashboard',
middleware: 'auth-board'
});
// Auth and permissions
const { firstName, isBoard, isAdmin } = useAuth();
const canCreateMembers = computed(() => isBoard.value || isAdmin.value);
const canEditMembers = computed(() => isBoard.value || isAdmin.value);
const canDeleteMembers = computed(() => isAdmin.value);
// Reactive data
const members = ref<Member[]>([]);
const loading = ref(true);
const error = ref('');
// Search and filtering
const searchTerm = ref('');
const nationalityFilter = ref('');
const statusFilter = ref('');
// Dialogs
const showAddDialog = ref(false);
const showEditDialog = ref(false);
const showViewDialog = ref(false);
const showDeleteDialog = ref(false);
const selectedMember = ref<Member | null>(null);
const deleteLoading = ref(false);
// Success handling
const showSuccess = ref(false);
const successMessage = ref('');
// Filter options
const statusOptions = [
{ title: 'Active', value: 'Active' },
{ title: 'Inactive', value: 'Inactive' },
{ title: 'Pending', value: 'Pending' },
{ title: 'Expired', value: 'Expired' }
];
const nationalityOptions = computed(() => {
const countries = getAllCountries();
return countries.map(country => ({
title: country.name,
value: country.code,
code: country.code,
name: country.name
}));
});
// Computed properties
const filteredMembers = computed(() => {
let filtered = [...members.value];
// Search filter
if (searchTerm.value) {
const search = searchTerm.value.toLowerCase();
filtered = filtered.filter(member =>
member.FullName?.toLowerCase().includes(search) ||
member.Email?.toLowerCase().includes(search) ||
member.Phone?.includes(search)
);
}
// Nationality filter
if (nationalityFilter.value) {
filtered = filtered.filter(member =>
member.Nationality === nationalityFilter.value
);
}
// Status filter
if (statusFilter.value) {
filtered = filtered.filter(member =>
member['Membership Status'] === statusFilter.value
);
}
return filtered;
});
const totalMembers = computed(() => members.value.length);
const activeMembers = computed(() =>
members.value.filter(m => m['Membership Status'] === 'Active').length
);
const paidDuesMembers = computed(() =>
members.value.filter(m => m['Current Year Dues Paid'] === 'true').length
);
const uniqueNationalities = computed(() => {
const nationalities = new Set(members.value.map(m => m.Nationality).filter(Boolean));
return nationalities.size;
});
// Methods
const loadMembers = async () => {
loading.value = true;
error.value = '';
try {
const response = await $fetch<{ success: boolean; data: { list: Member[] } }>('/api/members');
if (response.success) {
members.value = response.data.list || [];
} else {
throw new Error('Failed to load members');
}
} catch (err: any) {
console.error('Error loading members:', err);
error.value = err.message || 'Failed to load members. Please try again.';
} finally {
loading.value = false;
}
};
// Simple debounce function
const debouncedSearch = (() => {
let timeout: NodeJS.Timeout;
return () => {
clearTimeout(timeout);
timeout = setTimeout(() => {
// Search happens automatically via computed
}, 300);
};
})();
const filterMembers = () => {
// Filtering happens automatically via computed
};
const viewMember = (member: Member) => {
selectedMember.value = member;
showViewDialog.value = true;
};
const editMember = (member: Member) => {
selectedMember.value = member;
showEditDialog.value = true;
};
const confirmDeleteMember = (member: Member) => {
selectedMember.value = member;
showDeleteDialog.value = true;
};
const deleteMember = async () => {
if (!selectedMember.value) return;
deleteLoading.value = true;
try {
const response = await $fetch<{ success: boolean; message?: string }>(`/api/members/${selectedMember.value.Id}`, {
method: 'DELETE'
});
if (response.success) {
// Remove from local array
const index = members.value.findIndex(m => m.Id === selectedMember.value?.Id);
if (index !== -1) {
members.value.splice(index, 1);
}
showSuccess.value = true;
successMessage.value = `${selectedMember.value.FullName} has been deleted successfully.`;
showDeleteDialog.value = false;
selectedMember.value = null;
}
} catch (err: any) {
console.error('Error deleting member:', err);
error.value = err.message || 'Failed to delete member. Please try again.';
} finally {
deleteLoading.value = false;
}
};
const handleMemberCreated = (newMember: Member) => {
members.value.unshift(newMember);
showSuccess.value = true;
successMessage.value = `${newMember.FullName} has been added successfully.`;
};
const handleMemberUpdated = (updatedMember: Member) => {
const index = members.value.findIndex(m => m.Id === updatedMember.Id);
if (index !== -1) {
members.value[index] = updatedMember;
}
showSuccess.value = true;
successMessage.value = `${updatedMember.FullName} has been updated successfully.`;
};
// Load members on mount
onMounted(() => {
loadMembers();
});
</script>
<style scoped>
.v-card {
border-radius: 12px !important;
transition: all 0.3s ease;
}
.v-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15) !important;
}
.member-grid {
min-height: 400px;
}
.text-primary {
color: #a31515 !important;
}
</style>

View File

@ -3,7 +3,6 @@
<v-container fluid class="fill-height"> <v-container fluid class="fill-height">
<v-row class="fill-height" justify="center" align="center"> <v-row class="fill-height" justify="center" align="center">
<v-col cols="12" sm="8" md="6" lg="4" xl="3"> <v-col cols="12" sm="8" md="6" lg="4" xl="3">
<transition name="login-form" appear>
<v-card class="login-card" elevation="24"> <v-card class="login-card" elevation="24">
<v-card-text class="pa-8"> <v-card-text class="pa-8">
<!-- Logo and Welcome --> <!-- Logo and Welcome -->
@ -12,7 +11,7 @@
src="/MONACOUSA-Flags_376x376.png" src="/MONACOUSA-Flags_376x376.png"
width="120" width="120"
height="120" height="120"
class="mx-auto mb-4 pulse-animation" class="mx-auto mb-4"
alt="MonacoUSA Logo" alt="MonacoUSA Logo"
/> />
<h1 class="text-h4 font-weight-bold mb-2" style="color: #a31515;"> <h1 class="text-h4 font-weight-bold mb-2" style="color: #a31515;">
@ -89,22 +88,15 @@
:loading="loading" :loading="loading"
:disabled="!isFormValid" :disabled="!isFormValid"
class="mb-4" class="mb-4"
style="background-color: #a31515 !important;" style="background-color: #a31515 !important; color: white !important;"
> >
<v-icon left>mdi-login</v-icon> <v-icon left>mdi-login</v-icon>
Sign In Sign In
</v-btn> </v-btn>
</v-form> </v-form>
<!-- Additional Options -->
<div class="text-center">
<p class="text-body-2 text-medium-emphasis">
Need help? Contact your administrator
</p>
</div>
</v-card-text> </v-card-text>
</v-card> </v-card>
</transition>
</v-col> </v-col>
</v-row> </v-row>
</v-container> </v-container>
@ -246,38 +238,6 @@ onMounted(() => {
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4) !important; box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4) !important;
} }
.pulse-animation {
animation: pulse 3s infinite;
}
@keyframes pulse {
0% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.05);
opacity: 0.9;
}
100% {
transform: scale(1);
opacity: 1;
}
}
.login-form-enter-active {
transition: all 0.6s ease;
}
.login-form-enter-from {
opacity: 0;
transform: translateY(30px) scale(0.95);
}
.login-form-enter-to {
opacity: 1;
transform: translateY(0) scale(1);
}
/* Custom scrollbar for mobile */ /* Custom scrollbar for mobile */
::-webkit-scrollbar { ::-webkit-scrollbar {

View File

@ -0,0 +1,56 @@
import { deleteMember, handleNocoDbError } from '~/server/utils/nocodb';
import { createSessionManager } from '~/server/utils/session';
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id');
console.log('[api/members/[id].delete] =========================');
console.log('[api/members/[id].delete] DELETE /api/members/' + id);
console.log('[api/members/[id].delete] Request from:', getClientIP(event));
if (!id) {
throw createError({
statusCode: 400,
statusMessage: 'Member ID is required'
});
}
try {
// Validate session and require Admin privileges (delete is more sensitive)
const sessionManager = createSessionManager();
const cookieHeader = getCookie(event, 'monacousa-session') ? getHeader(event, 'cookie') : undefined;
const session = sessionManager.getSession(cookieHeader);
if (!session?.user) {
throw createError({
statusCode: 401,
statusMessage: 'Authentication required'
});
}
const userTier = session.user.tier;
if (userTier !== 'admin') {
throw createError({
statusCode: 403,
statusMessage: 'Administrator privileges required to delete members'
});
}
console.log('[api/members/[id].delete] Authorized user:', session.user.email, 'Tier:', userTier);
// Delete member from NocoDB
const result = await deleteMember(id);
console.log('[api/members/[id].delete] ✅ Member deleted successfully:', id);
return {
success: true,
data: { id },
message: 'Member deleted successfully'
};
} catch (error: any) {
console.error('[api/members/[id].delete] ❌ Error deleting member:', error);
handleNocoDbError(error, 'deleteMember', 'Member');
}
});

View File

@ -0,0 +1,49 @@
import { getMemberById, handleNocoDbError } from '~/server/utils/nocodb';
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id');
console.log('[api/members/[id].get] =========================');
console.log('[api/members/[id].get] GET /api/members/' + id);
console.log('[api/members/[id].get] Request from:', getClientIP(event));
if (!id) {
throw createError({
statusCode: 400,
statusMessage: 'Member ID is required'
});
}
try {
const member = await getMemberById(id);
// Add computed fields
const processedMember = {
...member,
FullName: `${member['First Name'] || ''} ${member['Last Name'] || ''}`.trim(),
FormattedPhone: formatPhoneNumber(member.Phone)
};
console.log('[api/members/[id].get] ✅ Successfully retrieved member:', id);
return {
success: true,
data: processedMember
};
} catch (error: any) {
console.error('[api/members/[id].get] ❌ Error fetching member:', error);
handleNocoDbError(error, 'getMemberById', 'Member');
}
});
function formatPhoneNumber(phone: string): string {
if (!phone) return '';
const cleaned = phone.replace(/\D/g, '');
if (cleaned.length === 10) {
return `(${cleaned.substring(0, 3)}) ${cleaned.substring(3, 6)}-${cleaned.substring(6)}`;
} else if (cleaned.length === 11 && cleaned.startsWith('1')) {
return `+1 (${cleaned.substring(1, 4)}) ${cleaned.substring(4, 7)}-${cleaned.substring(7)}`;
}
return phone;
}

View File

@ -0,0 +1,164 @@
import { updateMember, handleNocoDbError } from '~/server/utils/nocodb';
import { createSessionManager } from '~/server/utils/session';
import type { Member, MembershipStatus } from '~/utils/types';
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id');
console.log('[api/members/[id].put] =========================');
console.log('[api/members/[id].put] PUT /api/members/' + id);
console.log('[api/members/[id].put] Request from:', getClientIP(event));
if (!id) {
throw createError({
statusCode: 400,
statusMessage: 'Member ID is required'
});
}
try {
// Validate session and require Board+ privileges
const sessionManager = createSessionManager();
const cookieHeader = getCookie(event, 'monacousa-session') ? getHeader(event, 'cookie') : undefined;
const session = sessionManager.getSession(cookieHeader);
if (!session?.user) {
throw createError({
statusCode: 401,
statusMessage: 'Authentication required'
});
}
const userTier = session.user.tier;
if (userTier !== 'board' && userTier !== 'admin') {
throw createError({
statusCode: 403,
statusMessage: 'Board member privileges required to update members'
});
}
console.log('[api/members/[id].put] Authorized user:', session.user.email, 'Tier:', userTier);
// Get and validate request body
const body = await readBody(event);
console.log('[api/members/[id].put] Request body fields:', Object.keys(body));
// Validate updated fields
const validationErrors = validateMemberUpdateData(body);
if (validationErrors.length > 0) {
console.error('[api/members/[id].put] Validation errors:', validationErrors);
throw createError({
statusCode: 400,
statusMessage: `Validation failed: ${validationErrors.join(', ')}`
});
}
// Sanitize and prepare data
const memberData = sanitizeMemberUpdateData(body);
console.log('[api/members/[id].put] Sanitized data fields:', Object.keys(memberData));
// Update member in NocoDB
const updatedMember = await updateMember(id, memberData);
console.log('[api/members/[id].put] ✅ Member updated successfully:', id);
// Return processed member
const processedMember = {
...updatedMember,
FullName: `${updatedMember['First Name'] || ''} ${updatedMember['Last Name'] || ''}`.trim(),
FormattedPhone: formatPhoneNumber(updatedMember.Phone)
};
return {
success: true,
data: processedMember,
message: 'Member updated successfully'
};
} catch (error: any) {
console.error('[api/members/[id].put] ❌ Error updating member:', error);
handleNocoDbError(error, 'updateMember', 'Member');
}
});
function validateMemberUpdateData(data: any): string[] {
const errors: string[] = [];
// Only validate fields that are provided (partial updates allowed)
if (data['First Name'] !== undefined) {
if (!data['First Name'] || typeof data['First Name'] !== 'string' || data['First Name'].trim().length < 2) {
errors.push('First Name must be at least 2 characters');
}
}
if (data['Last Name'] !== undefined) {
if (!data['Last Name'] || typeof data['Last Name'] !== 'string' || data['Last Name'].trim().length < 2) {
errors.push('Last Name must be at least 2 characters');
}
}
if (data.Email !== undefined) {
if (!data.Email || typeof data.Email !== 'string' || !isValidEmail(data.Email)) {
errors.push('Valid email address is required');
}
}
// Optional field validation
if (data.Phone !== undefined && data.Phone && typeof data.Phone === 'string' && data.Phone.trim()) {
const phoneRegex = /^[\+]?[1-9][\d]{0,15}$/;
const cleanPhone = data.Phone.replace(/\D/g, '');
if (!phoneRegex.test(cleanPhone)) {
errors.push('Phone number format is invalid');
}
}
if (data['Membership Status'] !== undefined && !['Active', 'Inactive', 'Pending', 'Expired'].includes(data['Membership Status'])) {
errors.push('Invalid membership status');
}
return errors;
}
function sanitizeMemberUpdateData(data: any): Partial<Member> {
const sanitized: any = {};
// Only include fields that are provided (partial updates)
if (data['First Name'] !== undefined) sanitized['First Name'] = data['First Name'].trim();
if (data['Last Name'] !== undefined) sanitized['Last Name'] = data['Last Name'].trim();
if (data.Email !== undefined) sanitized['Email'] = data.Email.trim().toLowerCase();
if (data.Phone !== undefined) sanitized.Phone = data.Phone ? data.Phone.trim() : null;
if (data.Nationality !== undefined) sanitized.Nationality = data.Nationality ? data.Nationality.trim() : null;
if (data.Address !== undefined) sanitized.Address = data.Address ? data.Address.trim() : null;
if (data['Date of Birth'] !== undefined) sanitized['Date of Birth'] = data['Date of Birth'];
if (data['Member Since'] !== undefined) sanitized['Member Since'] = data['Member Since'];
if (data['Membership Date Paid'] !== undefined) sanitized['Membership Date Paid'] = data['Membership Date Paid'];
if (data['Payment Due Date'] !== undefined) sanitized['Payment Due Date'] = data['Payment Due Date'];
// Boolean fields
if (data['Current Year Dues Paid'] !== undefined) {
sanitized['Current Year Dues Paid'] = Boolean(data['Current Year Dues Paid']);
}
// Enum fields
if (data['Membership Status'] !== undefined) {
sanitized['Membership Status'] = data['Membership Status'];
}
return sanitized;
}
function isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
function formatPhoneNumber(phone: string): string {
if (!phone) return '';
const cleaned = phone.replace(/\D/g, '');
if (cleaned.length === 10) {
return `(${cleaned.substring(0, 3)}) ${cleaned.substring(3, 6)}-${cleaned.substring(6)}`;
} else if (cleaned.length === 11 && cleaned.startsWith('1')) {
return `+1 (${cleaned.substring(1, 4)}) ${cleaned.substring(4, 7)}-${cleaned.substring(7)}`;
}
return phone;
}

View File

@ -0,0 +1,102 @@
import { getMembers, handleNocoDbError } from '~/server/utils/nocodb';
import type { Member } from '~/utils/types';
export default defineEventHandler(async (event) => {
console.log('[api/members.get] =========================');
console.log('[api/members.get] GET /api/members - List all members');
console.log('[api/members.get] Request from:', getClientIP(event));
try {
// Get query parameters
const query = getQuery(event);
const limit = parseInt(query.limit as string) || 1000;
const searchTerm = query.search as string;
const nationality = query.nationality as string;
const membershipStatus = query.status as string;
const duesPaid = query.duesPaid as string;
console.log('[api/members.get] Query parameters:', {
limit,
searchTerm,
nationality,
membershipStatus,
duesPaid
});
// Fetch members from NocoDB
const result = await getMembers();
let members = result.list || [];
console.log('[api/members.get] Fetched members count:', members.length);
// Apply client-side filtering since NocoDB filtering can be complex
if (searchTerm) {
const search = searchTerm.toLowerCase();
members = members.filter(member =>
member['First Name']?.toLowerCase().includes(search) ||
member['Last Name']?.toLowerCase().includes(search) ||
member.Email?.toLowerCase().includes(search)
);
console.log('[api/members.get] After search filter:', members.length);
}
if (nationality) {
members = members.filter(member => member.Nationality === nationality);
console.log('[api/members.get] After nationality filter:', members.length);
}
if (membershipStatus) {
members = members.filter(member => member['Membership Status'] === membershipStatus);
console.log('[api/members.get] After status filter:', members.length);
}
if (duesPaid === 'true' || duesPaid === 'false') {
members = members.filter(member => member['Current Year Dues Paid'] === duesPaid);
console.log('[api/members.get] After dues filter:', members.length);
}
// Add computed fields
const processedMembers = members.map(member => ({
...member,
FullName: `${member['First Name'] || ''} ${member['Last Name'] || ''}`.trim(),
FormattedPhone: formatPhoneNumber(member.Phone)
}));
console.log('[api/members.get] ✅ Successfully processed', processedMembers.length, 'members');
return {
success: true,
data: {
list: processedMembers,
totalCount: processedMembers.length,
filters: {
searchTerm,
nationality,
membershipStatus,
duesPaid: duesPaid ? duesPaid === 'true' : undefined
}
}
};
} catch (error: any) {
console.error('[api/members.get] ❌ Error fetching members:', error);
handleNocoDbError(error, 'getMembers', 'Members');
}
});
// Utility function to format phone numbers
function formatPhoneNumber(phone: string): string {
if (!phone) return '';
// Remove all non-digits
const cleaned = phone.replace(/\D/g, '');
// Format based on length
if (cleaned.length === 10) {
return `(${cleaned.substring(0, 3)}) ${cleaned.substring(3, 6)}-${cleaned.substring(6)}`;
} else if (cleaned.length === 11 && cleaned.startsWith('1')) {
return `+1 (${cleaned.substring(1, 4)}) ${cleaned.substring(4, 7)}-${cleaned.substring(7)}`;
}
return phone; // Return original if we can't format it
}

View File

@ -0,0 +1,147 @@
import { createMember, handleNocoDbError } from '~/server/utils/nocodb';
import { createSessionManager } from '~/server/utils/session';
import type { Member, MembershipStatus } from '~/utils/types';
export default defineEventHandler(async (event) => {
console.log('[api/members.post] =========================');
console.log('[api/members.post] POST /api/members - Create new member');
console.log('[api/members.post] Request from:', getClientIP(event));
try {
// Validate session and require Board+ privileges
const sessionManager = createSessionManager();
const cookieHeader = getCookie(event, 'monacousa-session') ? getHeader(event, 'cookie') : undefined;
const session = sessionManager.getSession(cookieHeader);
if (!session?.user) {
throw createError({
statusCode: 401,
statusMessage: 'Authentication required'
});
}
const userTier = session.user.tier;
if (userTier !== 'board' && userTier !== 'admin') {
throw createError({
statusCode: 403,
statusMessage: 'Board member privileges required to create members'
});
}
console.log('[api/members.post] Authorized user:', session.user.email, 'Tier:', userTier);
// Get and validate request body
const body = await readBody(event);
console.log('[api/members.post] Request body fields:', Object.keys(body));
// Validate required fields
const validationErrors = validateMemberData(body);
if (validationErrors.length > 0) {
console.error('[api/members.post] Validation errors:', validationErrors);
throw createError({
statusCode: 400,
statusMessage: `Validation failed: ${validationErrors.join(', ')}`
});
}
// Sanitize and prepare data
const memberData = sanitizeMemberData(body);
console.log('[api/members.post] Sanitized data fields:', Object.keys(memberData));
// Create member in NocoDB
const newMember = await createMember(memberData);
console.log('[api/members.post] ✅ Member created successfully with ID:', newMember.Id);
// Return processed member
const processedMember = {
...newMember,
FullName: `${newMember['First Name'] || ''} ${newMember['Last Name'] || ''}`.trim(),
FormattedPhone: formatPhoneNumber(newMember.Phone)
};
return {
success: true,
data: processedMember,
message: 'Member created successfully'
};
} catch (error: any) {
console.error('[api/members.post] ❌ Error creating member:', error);
handleNocoDbError(error, 'createMember', 'Member');
}
});
function validateMemberData(data: any): string[] {
const errors: string[] = [];
// Required fields
if (!data['First Name'] || typeof data['First Name'] !== 'string' || data['First Name'].trim().length < 2) {
errors.push('First Name is required and must be at least 2 characters');
}
if (!data['Last Name'] || typeof data['Last Name'] !== 'string' || data['Last Name'].trim().length < 2) {
errors.push('Last Name is required and must be at least 2 characters');
}
if (!data.Email || typeof data.Email !== 'string' || !isValidEmail(data.Email)) {
errors.push('Valid email address is required');
}
// Optional field validation
if (data.Phone && typeof data.Phone === 'string' && data.Phone.trim()) {
const phoneRegex = /^[\+]?[1-9][\d]{0,15}$/;
const cleanPhone = data.Phone.replace(/\D/g, '');
if (!phoneRegex.test(cleanPhone)) {
errors.push('Phone number format is invalid');
}
}
if (data['Membership Status'] && !['Active', 'Inactive', 'Pending', 'Expired'].includes(data['Membership Status'])) {
errors.push('Invalid membership status');
}
return errors;
}
function sanitizeMemberData(data: any): Partial<Member> {
const sanitized: any = {};
// Required fields
sanitized['First Name'] = data['First Name'].trim();
sanitized['Last Name'] = data['Last Name'].trim();
sanitized['Email'] = data.Email.trim().toLowerCase();
// Optional fields
if (data.Phone) sanitized.Phone = data.Phone.trim();
if (data.Nationality) sanitized.Nationality = data.Nationality.trim();
if (data.Address) sanitized.Address = data.Address.trim();
if (data['Date of Birth']) sanitized['Date of Birth'] = data['Date of Birth'];
if (data['Member Since']) sanitized['Member Since'] = data['Member Since'];
if (data['Membership Date Paid']) sanitized['Membership Date Paid'] = data['Membership Date Paid'];
if (data['Payment Due Date']) sanitized['Payment Due Date'] = data['Payment Due Date'];
// Boolean fields
sanitized['Current Year Dues Paid'] = Boolean(data['Current Year Dues Paid']);
// Enum fields
sanitized['Membership Status'] = data['Membership Status'] || 'Pending';
return sanitized;
}
function isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
function formatPhoneNumber(phone: string): string {
if (!phone) return '';
const cleaned = phone.replace(/\D/g, '');
if (cleaned.length === 10) {
return `(${cleaned.substring(0, 3)}) ${cleaned.substring(3, 6)}-${cleaned.substring(6)}`;
} else if (cleaned.length === 11 && cleaned.startsWith('1')) {
return `+1 (${cleaned.substring(1, 4)}) ${cleaned.substring(4, 7)}-${cleaned.substring(7)}`;
}
return phone;
}

384
server/utils/nocodb.ts Normal file
View File

@ -0,0 +1,384 @@
import type { Member, MembershipStatus, MemberResponse, NocoDBSettings } from '~/utils/types';
// Data normalization functions
export const normalizePersonName = (name: string): string => {
if (!name) return 'Unknown';
// Trim whitespace and normalize case
return name.trim()
.split(' ')
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ');
};
// Pagination interface
export interface PageInfo {
pageSize: number;
totalRows: number;
isFirstPage: boolean;
isLastPage: boolean;
page: number;
}
// Response interfaces
export interface EntityResponse<T> {
list: T[];
PageInfo: PageInfo;
}
// Table ID enumeration - Replace with your actual table IDs
export enum Table {
Members = "members-table-id", // Will be configured via admin panel
}
/**
* Convert date from DD-MM-YYYY format to YYYY-MM-DD format for PostgreSQL
*/
const convertDateFormat = (dateString: string): string => {
if (!dateString) return dateString;
// If it's already in ISO format or contains 'T', return as is
if (dateString.includes('T') || dateString.match(/^\d{4}-\d{2}-\d{2}/)) {
return dateString;
}
// Handle DD-MM-YYYY format
const ddmmyyyyMatch = dateString.match(/^(\d{1,2})-(\d{1,2})-(\d{4})$/);
if (ddmmyyyyMatch) {
const [, day, month, year] = ddmmyyyyMatch;
const convertedDate = `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
console.log(`[convertDateFormat] Converted ${dateString} to ${convertedDate}`);
return convertedDate;
}
// Handle DD/MM/YYYY format
const ddmmyyyySlashMatch = dateString.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
if (ddmmyyyySlashMatch) {
const [, day, month, year] = ddmmyyyySlashMatch;
const convertedDate = `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
console.log(`[convertDateFormat] Converted ${dateString} to ${convertedDate}`);
return convertedDate;
}
console.warn(`[convertDateFormat] Could not parse date format: ${dateString}`);
return dateString;
};
// String data handling functions
export const parseStringBoolean = (value: string): boolean => {
return value === 'true';
};
export const formatBooleanAsString = (value: boolean): string => {
return value ? 'true' : 'false';
};
export const parseNationalities = (nationalityString: string): string[] => {
return nationalityString ? nationalityString.split(',').map(n => n.trim()).filter(n => n.length > 0) : [];
};
export const formatNationalitiesAsString = (nationalities: string[]): string => {
return nationalities.filter(n => n && n.trim()).join(',');
};
export const getNocoDbConfiguration = () => {
const config = useRuntimeConfig().nocodb;
// Use the new database URL
const updatedConfig = {
...config,
url: 'https://database.monacousa.org'
};
console.log('[nocodb] Configuration URL:', updatedConfig.url);
return updatedConfig;
};
export const createTableUrl = (table: Table) => {
const url = `${getNocoDbConfiguration().url}/api/v2/tables/${table}/records`;
console.log('[nocodb] Table URL:', url);
return url;
};
// CRUD operations for Members table
export const getMembers = async (): Promise<EntityResponse<Member>> => {
console.log('[nocodb.getMembers] Fetching all members...');
const startTime = Date.now();
try {
const result = await $fetch<EntityResponse<Member>>(createTableUrl(Table.Members), {
headers: {
"xc-token": getNocoDbConfiguration().token,
},
params: {
limit: 1000,
},
});
console.log('[nocodb.getMembers] Successfully fetched members, count:', result.list?.length || 0);
console.log('[nocodb.getMembers] Request duration:', Date.now() - startTime, 'ms');
return result;
} catch (error: any) {
console.error('[nocodb.getMembers] Error fetching members:', error);
throw error;
}
};
export const getMemberById = async (id: string): Promise<Member> => {
console.log('[nocodb.getMemberById] Fetching member ID:', id);
const result = await $fetch<Member>(`${createTableUrl(Table.Members)}/${id}`, {
headers: {
"xc-token": getNocoDbConfiguration().token,
},
});
console.log('[nocodb.getMemberById] Successfully retrieved member:', result.Id);
return result;
};
export const createMember = async (data: Partial<Member>): Promise<Member> => {
console.log('[nocodb.createMember] Creating member with fields:', Object.keys(data));
// Create a clean data object that matches the member schema
const cleanData: Record<string, any> = {};
// Only include fields that are part of the member schema
const allowedFields = [
"First Name",
"Last Name",
"Email",
"Phone",
"Current Year Dues Paid",
"Nationality",
"Date of Birth",
"Membership Date Paid",
"Payment Due Date",
"Membership Status",
"Address",
"Member Since"
];
// Filter the data to only include allowed fields
for (const field of allowedFields) {
if (field in data) {
cleanData[field] = (data as any)[field];
}
}
// Remove any computed or relation fields that shouldn't be sent
delete cleanData.Id;
delete cleanData.CreatedAt;
delete cleanData.UpdatedAt;
delete cleanData.FullName;
delete cleanData.FormattedPhone;
// Fix date formatting for PostgreSQL
if (cleanData['Date of Birth']) {
cleanData['Date of Birth'] = convertDateFormat(cleanData['Date of Birth']);
}
if (cleanData['Membership Date Paid']) {
cleanData['Membership Date Paid'] = convertDateFormat(cleanData['Membership Date Paid']);
}
if (cleanData['Payment Due Date']) {
cleanData['Payment Due Date'] = convertDateFormat(cleanData['Payment Due Date']);
}
console.log('[nocodb.createMember] Clean data fields:', Object.keys(cleanData));
const url = createTableUrl(Table.Members);
try {
const result = await $fetch<Member>(url, {
method: "POST",
headers: {
"xc-token": getNocoDbConfiguration().token,
},
body: cleanData,
});
console.log('[nocodb.createMember] Created member with ID:', result.Id);
return result;
} catch (error) {
console.error('[nocodb.createMember] Create failed:', error);
console.error('[nocodb.createMember] Error details:', error instanceof Error ? error.message : 'Unknown error');
throw error;
}
};
export const updateMember = async (id: string, data: Partial<Member>, retryCount = 0): Promise<Member> => {
console.log('[nocodb.updateMember] Updating member:', id, 'Retry:', retryCount);
console.log('[nocodb.updateMember] Data fields:', Object.keys(data));
// First, try to verify the record exists
if (retryCount === 0) {
try {
console.log('[nocodb.updateMember] Verifying record exists...');
const existingRecord = await getMemberById(id);
console.log('[nocodb.updateMember] Record exists with ID:', existingRecord.Id);
} catch (verifyError: any) {
console.error('[nocodb.updateMember] Failed to verify record:', verifyError);
if (verifyError.statusCode === 404 || verifyError.status === 404) {
console.error('[nocodb.updateMember] Record verification failed - record not found');
}
}
}
// Create a clean data object
const cleanData: Record<string, any> = {};
// Only include fields that are part of the member schema
const allowedFields = [
"First Name",
"Last Name",
"Email",
"Phone",
"Current Year Dues Paid",
"Nationality",
"Date of Birth",
"Membership Date Paid",
"Payment Due Date",
"Membership Status",
"Address",
"Member Since"
];
// Filter the data to only include allowed fields
for (const field of allowedFields) {
if (field in data) {
const value = (data as any)[field];
// Handle clearing fields - NocoDB requires null for clearing, not undefined
if (value === undefined) {
cleanData[field] = null;
console.log(`[nocodb.updateMember] Converting undefined to null for field: ${field}`);
} else {
cleanData[field] = value;
}
}
}
// Fix date formatting for PostgreSQL
if (cleanData['Date of Birth']) {
cleanData['Date of Birth'] = convertDateFormat(cleanData['Date of Birth']);
}
if (cleanData['Membership Date Paid']) {
cleanData['Membership Date Paid'] = convertDateFormat(cleanData['Membership Date Paid']);
}
if (cleanData['Payment Due Date']) {
cleanData['Payment Due Date'] = convertDateFormat(cleanData['Payment Due Date']);
}
console.log('[nocodb.updateMember] Clean data fields:', Object.keys(cleanData));
// PATCH requires ID in the body (not in URL)
cleanData.Id = parseInt(id);
const url = createTableUrl(Table.Members);
try {
console.log('[nocodb.updateMember] Sending PATCH request');
const result = await $fetch<Member>(url, {
method: "PATCH",
headers: {
"xc-token": getNocoDbConfiguration().token,
"Content-Type": "application/json"
},
body: cleanData
});
console.log('[nocodb.updateMember] Update successful for ID:', id);
return result;
} catch (error: any) {
console.error('[nocodb.updateMember] Update failed:', error);
console.error('[nocodb.updateMember] Error details:', error instanceof Error ? error.message : 'Unknown error');
// If it's a 404 error and we haven't retried too many times, wait and retry
if ((error.statusCode === 404 || error.status === 404) && retryCount < 3) {
console.error('[nocodb.updateMember] 404 Error - Record not found. This might be a sync delay.');
console.error(`Retrying in ${(retryCount + 1) * 1000}ms... (Attempt ${retryCount + 1}/3)`);
// Wait with exponential backoff
await new Promise(resolve => setTimeout(resolve, (retryCount + 1) * 1000));
// Retry the update
return updateMember(id, data, retryCount + 1);
}
throw error;
}
};
export const deleteMember = async (id: string) => {
const startTime = Date.now();
console.log('[nocodb.deleteMember] =========================');
console.log('[nocodb.deleteMember] DELETE operation started at:', new Date().toISOString());
console.log('[nocodb.deleteMember] Target ID:', id);
const url = createTableUrl(Table.Members);
console.log('[nocodb.deleteMember] URL:', url);
const requestBody = {
"Id": parseInt(id)
};
console.log('[nocodb.deleteMember] Request configuration:');
console.log(' Method: DELETE');
console.log(' URL:', url);
console.log(' Body:', JSON.stringify(requestBody, null, 2));
try {
const result = await $fetch(url, {
method: "DELETE",
headers: {
"xc-token": getNocoDbConfiguration().token,
"Content-Type": "application/json"
},
body: requestBody
});
console.log('[nocodb.deleteMember] DELETE successful');
console.log('[nocodb.deleteMember] Duration:', Date.now() - startTime, 'ms');
return result;
} catch (error: any) {
console.error('[nocodb.deleteMember] DELETE FAILED');
console.error('[nocodb.deleteMember] Error type:', error.constructor.name);
console.error('[nocodb.deleteMember] Error message:', error.message);
console.error('[nocodb.deleteMember] Duration:', Date.now() - startTime, 'ms');
throw error;
}
};
// Centralized error handling
export const handleNocoDbError = (error: any, operation: string, entityType: string = 'Member') => {
console.error(`[nocodb.${operation}] =========================`);
console.error(`[nocodb.${operation}] ERROR in ${operation} for ${entityType}`);
console.error(`[nocodb.${operation}] Error type:`, error.constructor?.name || 'Unknown');
console.error(`[nocodb.${operation}] Error status:`, error.statusCode || error.status || 'Unknown');
console.error(`[nocodb.${operation}] Error message:`, error.message || 'Unknown error');
console.error(`[nocodb.${operation}] Error data:`, error.data);
console.error(`[nocodb.${operation}] =========================`);
// Provide more specific error messages
if (error.statusCode === 401 || error.status === 401) {
throw createError({
statusCode: 401,
statusMessage: `Authentication failed when accessing ${entityType}. Please check your access permissions.`
});
} else if (error.statusCode === 403 || error.status === 403) {
throw createError({
statusCode: 403,
statusMessage: `Access denied to ${entityType}. This feature requires appropriate privileges.`
});
} else if (error.statusCode === 404 || error.status === 404) {
throw createError({
statusCode: 404,
statusMessage: `${entityType} not found. Please verify the record exists.`
});
} else if (error.code === 'NETWORK_ERROR' || error.code === 'TIMEOUT') {
throw createError({
statusCode: 503,
statusMessage: `${entityType} database is temporarily unavailable. Please try again in a moment.`
});
}
throw error;
};

352
utils/countries.ts Normal file
View File

@ -0,0 +1,352 @@
// Complete list of all countries with ISO 3166-1 alpha-2 codes
// This works with the flag-icons library for comprehensive coverage
export const COUNTRIES = [
{ code: 'AD', name: 'Andorra' },
{ code: 'AE', name: 'United Arab Emirates' },
{ code: 'AF', name: 'Afghanistan' },
{ code: 'AG', name: 'Antigua and Barbuda' },
{ code: 'AI', name: 'Anguilla' },
{ code: 'AL', name: 'Albania' },
{ code: 'AM', name: 'Armenia' },
{ code: 'AO', name: 'Angola' },
{ code: 'AQ', name: 'Antarctica' },
{ code: 'AR', name: 'Argentina' },
{ code: 'AS', name: 'American Samoa' },
{ code: 'AT', name: 'Austria' },
{ code: 'AU', name: 'Australia' },
{ code: 'AW', name: 'Aruba' },
{ code: 'AX', name: 'Åland Islands' },
{ code: 'AZ', name: 'Azerbaijan' },
{ code: 'BA', name: 'Bosnia and Herzegovina' },
{ code: 'BB', name: 'Barbados' },
{ code: 'BD', name: 'Bangladesh' },
{ code: 'BE', name: 'Belgium' },
{ code: 'BF', name: 'Burkina Faso' },
{ code: 'BG', name: 'Bulgaria' },
{ code: 'BH', name: 'Bahrain' },
{ code: 'BI', name: 'Burundi' },
{ code: 'BJ', name: 'Benin' },
{ code: 'BL', name: 'Saint Barthélemy' },
{ code: 'BM', name: 'Bermuda' },
{ code: 'BN', name: 'Brunei' },
{ code: 'BO', name: 'Bolivia' },
{ code: 'BQ', name: 'Bonaire, Sint Eustatius and Saba' },
{ code: 'BR', name: 'Brazil' },
{ code: 'BS', name: 'Bahamas' },
{ code: 'BT', name: 'Bhutan' },
{ code: 'BV', name: 'Bouvet Island' },
{ code: 'BW', name: 'Botswana' },
{ code: 'BY', name: 'Belarus' },
{ code: 'BZ', name: 'Belize' },
{ code: 'CA', name: 'Canada' },
{ code: 'CC', name: 'Cocos (Keeling) Islands' },
{ code: 'CD', name: 'Congo - Kinshasa' },
{ code: 'CF', name: 'Central African Republic' },
{ code: 'CG', name: 'Congo - Brazzaville' },
{ code: 'CH', name: 'Switzerland' },
{ code: 'CI', name: 'Côte d\'Ivoire' },
{ code: 'CK', name: 'Cook Islands' },
{ code: 'CL', name: 'Chile' },
{ code: 'CM', name: 'Cameroon' },
{ code: 'CN', name: 'China' },
{ code: 'CO', name: 'Colombia' },
{ code: 'CR', name: 'Costa Rica' },
{ code: 'CU', name: 'Cuba' },
{ code: 'CV', name: 'Cape Verde' },
{ code: 'CW', name: 'Curaçao' },
{ code: 'CX', name: 'Christmas Island' },
{ code: 'CY', name: 'Cyprus' },
{ code: 'CZ', name: 'Czech Republic' },
{ code: 'DE', name: 'Germany' },
{ code: 'DJ', name: 'Djibouti' },
{ code: 'DK', name: 'Denmark' },
{ code: 'DM', name: 'Dominica' },
{ code: 'DO', name: 'Dominican Republic' },
{ code: 'DZ', name: 'Algeria' },
{ code: 'EC', name: 'Ecuador' },
{ code: 'EE', name: 'Estonia' },
{ code: 'EG', name: 'Egypt' },
{ code: 'EH', name: 'Western Sahara' },
{ code: 'ER', name: 'Eritrea' },
{ code: 'ES', name: 'Spain' },
{ code: 'ET', name: 'Ethiopia' },
{ code: 'FI', name: 'Finland' },
{ code: 'FJ', name: 'Fiji' },
{ code: 'FK', name: 'Falkland Islands' },
{ code: 'FM', name: 'Micronesia' },
{ code: 'FO', name: 'Faroe Islands' },
{ code: 'FR', name: 'France' },
{ code: 'GA', name: 'Gabon' },
{ code: 'GB', name: 'United Kingdom' },
{ code: 'GD', name: 'Grenada' },
{ code: 'GE', name: 'Georgia' },
{ code: 'GF', name: 'French Guiana' },
{ code: 'GG', name: 'Guernsey' },
{ code: 'GH', name: 'Ghana' },
{ code: 'GI', name: 'Gibraltar' },
{ code: 'GL', name: 'Greenland' },
{ code: 'GM', name: 'Gambia' },
{ code: 'GN', name: 'Guinea' },
{ code: 'GP', name: 'Guadeloupe' },
{ code: 'GQ', name: 'Equatorial Guinea' },
{ code: 'GR', name: 'Greece' },
{ code: 'GS', name: 'South Georgia and the South Sandwich Islands' },
{ code: 'GT', name: 'Guatemala' },
{ code: 'GU', name: 'Guam' },
{ code: 'GW', name: 'Guinea-Bissau' },
{ code: 'GY', name: 'Guyana' },
{ code: 'HK', name: 'Hong Kong SAR China' },
{ code: 'HM', name: 'Heard & McDonald Islands' },
{ code: 'HN', name: 'Honduras' },
{ code: 'HR', name: 'Croatia' },
{ code: 'HT', name: 'Haiti' },
{ code: 'HU', name: 'Hungary' },
{ code: 'ID', name: 'Indonesia' },
{ code: 'IE', name: 'Ireland' },
{ code: 'IL', name: 'Israel' },
{ code: 'IM', name: 'Isle of Man' },
{ code: 'IN', name: 'India' },
{ code: 'IO', name: 'British Indian Ocean Territory' },
{ code: 'IQ', name: 'Iraq' },
{ code: 'IR', name: 'Iran' },
{ code: 'IS', name: 'Iceland' },
{ code: 'IT', name: 'Italy' },
{ code: 'JE', name: 'Jersey' },
{ code: 'JM', name: 'Jamaica' },
{ code: 'JO', name: 'Jordan' },
{ code: 'JP', name: 'Japan' },
{ code: 'KE', name: 'Kenya' },
{ code: 'KG', name: 'Kyrgyzstan' },
{ code: 'KH', name: 'Cambodia' },
{ code: 'KI', name: 'Kiribati' },
{ code: 'KM', name: 'Comoros' },
{ code: 'KN', name: 'Saint Kitts and Nevis' },
{ code: 'KP', name: 'North Korea' },
{ code: 'KR', name: 'South Korea' },
{ code: 'KW', name: 'Kuwait' },
{ code: 'KY', name: 'Cayman Islands' },
{ code: 'KZ', name: 'Kazakhstan' },
{ code: 'LA', name: 'Laos' },
{ code: 'LB', name: 'Lebanon' },
{ code: 'LC', name: 'Saint Lucia' },
{ code: 'LI', name: 'Liechtenstein' },
{ code: 'LK', name: 'Sri Lanka' },
{ code: 'LR', name: 'Liberia' },
{ code: 'LS', name: 'Lesotho' },
{ code: 'LT', name: 'Lithuania' },
{ code: 'LU', name: 'Luxembourg' },
{ code: 'LV', name: 'Latvia' },
{ code: 'LY', name: 'Libya' },
{ code: 'MA', name: 'Morocco' },
{ code: 'MC', name: 'Monaco' },
{ code: 'MD', name: 'Moldova' },
{ code: 'ME', name: 'Montenegro' },
{ code: 'MF', name: 'Saint Martin' },
{ code: 'MG', name: 'Madagascar' },
{ code: 'MH', name: 'Marshall Islands' },
{ code: 'MK', name: 'North Macedonia' },
{ code: 'ML', name: 'Mali' },
{ code: 'MM', name: 'Myanmar (Burma)' },
{ code: 'MN', name: 'Mongolia' },
{ code: 'MO', name: 'Macao SAR China' },
{ code: 'MP', name: 'Northern Mariana Islands' },
{ code: 'MQ', name: 'Martinique' },
{ code: 'MR', name: 'Mauritania' },
{ code: 'MS', name: 'Montserrat' },
{ code: 'MT', name: 'Malta' },
{ code: 'MU', name: 'Mauritius' },
{ code: 'MV', name: 'Maldives' },
{ code: 'MW', name: 'Malawi' },
{ code: 'MX', name: 'Mexico' },
{ code: 'MY', name: 'Malaysia' },
{ code: 'MZ', name: 'Mozambique' },
{ code: 'NA', name: 'Namibia' },
{ code: 'NC', name: 'New Caledonia' },
{ code: 'NE', name: 'Niger' },
{ code: 'NF', name: 'Norfolk Island' },
{ code: 'NG', name: 'Nigeria' },
{ code: 'NI', name: 'Nicaragua' },
{ code: 'NL', name: 'Netherlands' },
{ code: 'NO', name: 'Norway' },
{ code: 'NP', name: 'Nepal' },
{ code: 'NR', name: 'Nauru' },
{ code: 'NU', name: 'Niue' },
{ code: 'NZ', name: 'New Zealand' },
{ code: 'OM', name: 'Oman' },
{ code: 'PA', name: 'Panama' },
{ code: 'PE', name: 'Peru' },
{ code: 'PF', name: 'French Polynesia' },
{ code: 'PG', name: 'Papua New Guinea' },
{ code: 'PH', name: 'Philippines' },
{ code: 'PK', name: 'Pakistan' },
{ code: 'PL', name: 'Poland' },
{ code: 'PM', name: 'Saint Pierre and Miquelon' },
{ code: 'PN', name: 'Pitcairn Islands' },
{ code: 'PR', name: 'Puerto Rico' },
{ code: 'PS', name: 'Palestinian Territories' },
{ code: 'PT', name: 'Portugal' },
{ code: 'PW', name: 'Palau' },
{ code: 'PY', name: 'Paraguay' },
{ code: 'QA', name: 'Qatar' },
{ code: 'RE', name: 'Réunion' },
{ code: 'RO', name: 'Romania' },
{ code: 'RS', name: 'Serbia' },
{ code: 'RU', name: 'Russia' },
{ code: 'RW', name: 'Rwanda' },
{ code: 'SA', name: 'Saudi Arabia' },
{ code: 'SB', name: 'Solomon Islands' },
{ code: 'SC', name: 'Seychelles' },
{ code: 'SD', name: 'Sudan' },
{ code: 'SE', name: 'Sweden' },
{ code: 'SG', name: 'Singapore' },
{ code: 'SH', name: 'Saint Helena' },
{ code: 'SI', name: 'Slovenia' },
{ code: 'SJ', name: 'Svalbard and Jan Mayen' },
{ code: 'SK', name: 'Slovakia' },
{ code: 'SL', name: 'Sierra Leone' },
{ code: 'SM', name: 'San Marino' },
{ code: 'SN', name: 'Senegal' },
{ code: 'SO', name: 'Somalia' },
{ code: 'SR', name: 'Suriname' },
{ code: 'SS', name: 'South Sudan' },
{ code: 'ST', name: 'São Tomé and Príncipe' },
{ code: 'SV', name: 'El Salvador' },
{ code: 'SX', name: 'Sint Maarten' },
{ code: 'SY', name: 'Syria' },
{ code: 'SZ', name: 'Eswatini' },
{ code: 'TC', name: 'Turks and Caicos Islands' },
{ code: 'TD', name: 'Chad' },
{ code: 'TF', name: 'French Southern Territories' },
{ code: 'TG', name: 'Togo' },
{ code: 'TH', name: 'Thailand' },
{ code: 'TJ', name: 'Tajikistan' },
{ code: 'TK', name: 'Tokelau' },
{ code: 'TL', name: 'Timor-Leste' },
{ code: 'TM', name: 'Turkmenistan' },
{ code: 'TN', name: 'Tunisia' },
{ code: 'TO', name: 'Tonga' },
{ code: 'TR', name: 'Turkey' },
{ code: 'TT', name: 'Trinidad and Tobago' },
{ code: 'TV', name: 'Tuvalu' },
{ code: 'TW', name: 'Taiwan' },
{ code: 'TZ', name: 'Tanzania' },
{ code: 'UA', name: 'Ukraine' },
{ code: 'UG', name: 'Uganda' },
{ code: 'UM', name: 'U.S. Outlying Islands' },
{ code: 'US', name: 'United States' },
{ code: 'UY', name: 'Uruguay' },
{ code: 'UZ', name: 'Uzbekistan' },
{ code: 'VA', name: 'Vatican City' },
{ code: 'VC', name: 'Saint Vincent and the Grenadines' },
{ code: 'VE', name: 'Venezuela' },
{ code: 'VG', name: 'British Virgin Islands' },
{ code: 'VI', name: 'U.S. Virgin Islands' },
{ code: 'VN', name: 'Vietnam' },
{ code: 'VU', name: 'Vanuatu' },
{ code: 'WF', name: 'Wallis and Futuna' },
{ code: 'WS', name: 'Samoa' },
{ code: 'XK', name: 'Kosovo' },
{ code: 'YE', name: 'Yemen' },
{ code: 'YT', name: 'Mayotte' },
{ code: 'ZA', name: 'South Africa' },
{ code: 'ZM', name: 'Zambia' },
{ code: 'ZW', name: 'Zimbabwe' }
];
// Create lookup maps for fast access
const countryNamesByCode = new Map(
COUNTRIES.map(country => [country.code, country.name])
);
const countriesByName = new Map(
COUNTRIES.map(country => [country.name.toLowerCase(), country])
);
/**
* Get country name for a country code
*/
export const getCountryName = (countryCode: string): string => {
if (!countryCode) return 'Unknown';
const upperCode = countryCode.toUpperCase();
return countryNamesByCode.get(upperCode) || countryCode;
};
/**
* Get both flag and name formatted (for display)
*/
export const getCountryDisplay = (countryCode: string): string => {
if (!countryCode) return 'Unknown';
const name = getCountryName(countryCode);
return `${countryCode.toUpperCase()} - ${name}`;
};
/**
* Get all countries sorted by name for dropdowns
*/
export const getAllCountries = (): Array<{ code: string; name: string; display: string }> => {
return COUNTRIES
.map(country => ({
code: country.code,
name: country.name,
display: `${country.name}`
}))
.sort((a, b) => a.name.localeCompare(b.name));
};
/**
* Search countries by name or code (for autocomplete)
*/
export const searchCountries = (query: string): Array<{ code: string; name: string; display: string }> => {
if (!query) return getAllCountries();
const lowerQuery = query.toLowerCase();
return getAllCountries().filter(country =>
country.name.toLowerCase().includes(lowerQuery) ||
country.code.toLowerCase().includes(lowerQuery)
);
};
/**
* Parse country from various input formats
*/
export const parseCountryInput = (input: string): string | null => {
if (!input) return null;
const trimmed = input.trim();
// If it's already a country code and exists
if (trimmed.length === 2 && countryNamesByCode.has(trimmed.toUpperCase())) {
return trimmed.toUpperCase();
}
// Search by name
const found = countriesByName.get(trimmed.toLowerCase());
return found ? found.code : null;
};
/**
* Validate if a country code exists
*/
export const isValidCountryCode = (code: string): boolean => {
if (!code) return false;
return countryNamesByCode.has(code.toUpperCase());
};
/**
* Get countries by region/continent (basic grouping)
*/
export const getCountriesByRegion = (region: 'europe' | 'asia' | 'africa' | 'americas' | 'oceania'): Array<{ code: string; name: string }> => {
// Basic regional groupings - this could be expanded with more detailed data
const regions = {
europe: ['AD', 'AL', 'AM', 'AT', 'AZ', 'BA', 'BE', 'BG', 'BY', 'CH', 'CY', 'CZ', 'DE', 'DK', 'EE', 'ES', 'FI', 'FR', 'GB', 'GE', 'GR', 'HR', 'HU', 'IE', 'IS', 'IT', 'LI', 'LT', 'LU', 'LV', 'MC', 'MD', 'ME', 'MK', 'MT', 'NL', 'NO', 'PL', 'PT', 'RO', 'RS', 'RU', 'SE', 'SI', 'SK', 'SM', 'TR', 'UA', 'VA'],
asia: ['AF', 'BD', 'BH', 'BN', 'BT', 'CN', 'ID', 'IL', 'IN', 'IQ', 'IR', 'JO', 'JP', 'KG', 'KH', 'KP', 'KR', 'KW', 'KZ', 'LA', 'LB', 'LK', 'MM', 'MN', 'MV', 'MY', 'NP', 'OM', 'PH', 'PK', 'QA', 'SA', 'SG', 'SY', 'TH', 'TJ', 'TL', 'TM', 'TW', 'UZ', 'VN', 'YE'],
africa: ['AO', 'BF', 'BI', 'BJ', 'BW', 'CD', 'CF', 'CG', 'CI', 'CM', 'CV', 'DJ', 'DZ', 'EG', 'EH', 'ER', 'ET', 'GA', 'GH', 'GM', 'GN', 'GQ', 'GW', 'KE', 'KM', 'LR', 'LS', 'LY', 'MA', 'MG', 'ML', 'MR', 'MU', 'MW', 'MZ', 'NA', 'NE', 'NG', 'RW', 'SC', 'SD', 'SL', 'SN', 'SO', 'SS', 'ST', 'SZ', 'TD', 'TG', 'TN', 'TZ', 'UG', 'ZA', 'ZM', 'ZW'],
americas: ['AG', 'AR', 'BB', 'BZ', 'BO', 'BR', 'BS', 'CA', 'CL', 'CO', 'CR', 'CU', 'DM', 'DO', 'EC', 'GD', 'GT', 'GY', 'HN', 'HT', 'JM', 'KN', 'LC', 'MX', 'NI', 'PA', 'PE', 'PY', 'SR', 'SV', 'TT', 'US', 'UY', 'VC', 'VE'],
oceania: ['AU', 'FJ', 'KI', 'MH', 'FM', 'NR', 'NZ', 'PW', 'PG', 'SB', 'TO', 'TV', 'VU', 'WS']
};
const regionCodes = regions[region] || [];
return COUNTRIES.filter(country => regionCodes.includes(country.code));
};

View File

@ -107,3 +107,59 @@ export interface MinIOConfig {
secretKey: string; secretKey: string;
bucketName: string; bucketName: string;
} }
// Member Management Types
export enum MembershipStatus {
Active = 'Active',
Inactive = 'Inactive',
Pending = 'Pending',
Expired = 'Expired'
}
export interface Member {
Id: string;
"First Name": string;
"Last Name": string;
Email: string;
Phone: string;
"Current Year Dues Paid": string; // "true" or "false"
Nationality: string; // "FR,MC,US" for multiple nationalities
"Date of Birth": string;
"Membership Date Paid": string;
"Payment Due Date": string;
"Membership Status": string;
Address: string;
"Member Since": string;
// Computed fields (added by processing)
FullName?: string;
FormattedPhone?: string;
NationalityArray?: string[]; // Parsed from comma-separated string
}
// Admin-only NocoDB Configuration
export interface NocoDBSettings {
tableId: string;
apiKey: string;
baseId: string;
url: string;
}
export interface MemberResponse {
list: Member[];
PageInfo: {
pageSize: number;
totalRows: number;
isFirstPage: boolean;
isLastPage: boolean;
page: number;
};
}
export interface MemberFilters {
searchTerm?: string;
nationality?: string;
membershipStatus?: MembershipStatus;
duesPaid?: boolean;
memberSince?: string;
}