Add member management system with NocoDB integration
Build And Push Image / docker (push) Successful in 3m5s
Details
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:
parent
c84442433f
commit
af99ea48e2
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 = () => {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
@ -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));
|
||||||
|
};
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue