663 lines
19 KiB
Vue
663 lines
19 KiB
Vue
<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">
|
|
<ProfileAvatar
|
|
v-if="member"
|
|
:member-id="member.member_id"
|
|
:member-name="member.FullName || `${member.first_name} ${member.last_name}`"
|
|
:first-name="member.first_name"
|
|
:last-name="member.last_name"
|
|
size="large"
|
|
class="mr-4"
|
|
clickable
|
|
show-border
|
|
@click="openImageLightbox"
|
|
/>
|
|
<div class="flex-grow-1">
|
|
<h2 class="text-h5 text-white font-weight-bold">
|
|
Edit Member: {{ member?.FullName || `${member?.first_name} ${member?.last_name}` }}
|
|
</h2>
|
|
</div>
|
|
<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>
|
|
|
|
<!-- Portal Access Control Section (Admin Only) -->
|
|
<template v-if="isAdmin && member?.keycloak_id">
|
|
<v-col cols="12">
|
|
<v-divider class="my-4" />
|
|
<h3 class="text-h6 mb-4 text-primary">Portal Access Control</h3>
|
|
</v-col>
|
|
|
|
<v-col cols="12" md="6">
|
|
<v-select
|
|
v-model="form.portal_group"
|
|
:items="portalGroupOptions"
|
|
label="Portal Access Level"
|
|
variant="outlined"
|
|
hint="Controls user's access level in the portal"
|
|
persistent-hint
|
|
:loading="groupLoading"
|
|
:disabled="groupLoading"
|
|
:error="hasFieldError('portal_group')"
|
|
:error-messages="getFieldError('portal_group')"
|
|
>
|
|
<template #prepend-inner>
|
|
<v-icon color="primary">mdi-shield-account</v-icon>
|
|
</template>
|
|
</v-select>
|
|
</v-col>
|
|
|
|
<v-col cols="12" md="6">
|
|
<v-alert
|
|
v-if="groupSyncStatus"
|
|
:type="groupSyncStatus.type"
|
|
:text="groupSyncStatus.message"
|
|
density="compact"
|
|
class="mb-0"
|
|
/>
|
|
<v-chip
|
|
v-else-if="member.keycloak_id"
|
|
color="success"
|
|
size="small"
|
|
class="mt-2"
|
|
>
|
|
<v-icon start size="small">mdi-check-circle</v-icon>
|
|
Portal Account Active
|
|
</v-chip>
|
|
</v-col>
|
|
</template>
|
|
</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-content-save</v-icon>
|
|
Save Changes
|
|
</v-btn>
|
|
</v-card-actions>
|
|
</v-card>
|
|
</v-dialog>
|
|
|
|
<!-- Image Lightbox -->
|
|
<v-dialog
|
|
v-model="showImageLightbox"
|
|
max-width="800"
|
|
@click:outside="showImageLightbox = false"
|
|
>
|
|
<v-card class="pa-0" v-if="member && lightboxImageUrl">
|
|
<v-card-title class="d-flex align-center pa-4">
|
|
<span class="text-h6">{{ member.FullName || `${member.first_name} ${member.last_name}` }} - Profile Photo</span>
|
|
<v-spacer />
|
|
<v-btn
|
|
icon
|
|
variant="text"
|
|
@click="showImageLightbox = false"
|
|
>
|
|
<v-icon>mdi-close</v-icon>
|
|
</v-btn>
|
|
</v-card-title>
|
|
<v-card-text class="pa-4">
|
|
<div class="text-center">
|
|
<v-img
|
|
:src="lightboxImageUrl"
|
|
:alt="`${member.FullName || `${member.first_name} ${member.last_name}`} profile photo`"
|
|
max-height="500"
|
|
contain
|
|
class="mx-auto"
|
|
/>
|
|
</div>
|
|
</v-card-text>
|
|
</v-card>
|
|
</v-dialog>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import type { Member } from '~/utils/types';
|
|
|
|
interface Props {
|
|
modelValue: boolean;
|
|
member: Member | null;
|
|
}
|
|
|
|
interface Emits {
|
|
(e: 'update:model-value', value: boolean): void;
|
|
(e: 'member-updated', member: Member): void;
|
|
}
|
|
|
|
const props = defineProps<Props>();
|
|
const emit = defineEmits<Emits>();
|
|
|
|
// Form state
|
|
const formRef = ref();
|
|
const formValid = ref(false);
|
|
const loading = ref(false);
|
|
|
|
// Lightbox state
|
|
const showImageLightbox = ref(false);
|
|
const lightboxImageUrl = ref<string | null>(null);
|
|
|
|
// Form data - using snake_case field names
|
|
const form = ref({
|
|
first_name: '',
|
|
last_name: '',
|
|
email: '',
|
|
phone: '',
|
|
date_of_birth: '',
|
|
nationality: '',
|
|
address: '',
|
|
membership_status: 'Active',
|
|
member_since: '',
|
|
current_year_dues_paid: 'false',
|
|
membership_date_paid: '',
|
|
payment_due_date: '',
|
|
portal_group: 'user'
|
|
});
|
|
|
|
// Additional form state
|
|
const duesPaid = ref(false);
|
|
const phoneData = ref(null);
|
|
|
|
// Error handling
|
|
const fieldErrors = ref<Record<string, string>>({});
|
|
|
|
// Auth state
|
|
const { user, isAdmin } = useAuth();
|
|
|
|
// Portal group management
|
|
const groupLoading = ref(false);
|
|
const groupSyncStatus = ref<{ type: 'success' | 'warning' | 'error'; message: string } | null>(null);
|
|
const originalPortalGroup = ref<string>('user');
|
|
|
|
const portalGroupOptions = [
|
|
{ title: 'User - Basic Access', value: 'user' },
|
|
{ title: 'Board Member - Extended Access', value: 'board' },
|
|
{ title: 'Administrator - Full Access', value: 'admin' }
|
|
];
|
|
|
|
// Watch for portal group changes and sync with Keycloak
|
|
watch(() => form.value.portal_group, async (newGroup, oldGroup) => {
|
|
if (!props.member?.keycloak_id || !isAdmin || newGroup === oldGroup || newGroup === originalPortalGroup.value) {
|
|
return;
|
|
}
|
|
|
|
console.log('[EditMemberDialog] Portal group changed:', oldGroup, '->', newGroup);
|
|
|
|
groupLoading.value = true;
|
|
groupSyncStatus.value = null;
|
|
|
|
try {
|
|
console.log('[EditMemberDialog] Updating Keycloak groups for member:', props.member.Id);
|
|
|
|
const response = await $fetch(`/api/members/${props.member.Id}/keycloak-groups`, {
|
|
method: 'PUT',
|
|
body: { newGroup }
|
|
});
|
|
|
|
if (response.success) {
|
|
groupSyncStatus.value = {
|
|
type: 'success',
|
|
message: `Successfully changed access level to ${newGroup}`
|
|
};
|
|
originalPortalGroup.value = newGroup; // Update original to prevent re-trigger
|
|
console.log('[EditMemberDialog] Group change successful:', response.data);
|
|
} else {
|
|
throw new Error(response.message || 'Failed to update access level');
|
|
}
|
|
|
|
} catch (error: any) {
|
|
console.error('[EditMemberDialog] Failed to update Keycloak groups:', error);
|
|
|
|
groupSyncStatus.value = {
|
|
type: 'error',
|
|
message: error.data?.message || error.message || 'Failed to update access level'
|
|
};
|
|
|
|
// Revert the form value on error
|
|
form.value.portal_group = oldGroup || 'user';
|
|
|
|
} finally {
|
|
groupLoading.value = false;
|
|
|
|
// Clear status after 5 seconds
|
|
setTimeout(() => {
|
|
groupSyncStatus.value = null;
|
|
}, 5000);
|
|
}
|
|
});
|
|
|
|
// Watch dues paid switch
|
|
watch(duesPaid, (newValue) => {
|
|
form.value.current_year_dues_paid = newValue ? 'true' : 'false';
|
|
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 month from member since date or today
|
|
const memberSince = form.value.member_since || new Date().toISOString().split('T')[0];
|
|
const dueDate = new Date(memberSince);
|
|
dueDate.setMonth(dueDate.getMonth() + 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 pre-population - Updated to use snake_case field names
|
|
const populateForm = () => {
|
|
if (!props.member) return;
|
|
|
|
console.log('[EditMemberDialog] Populating form with member data:', props.member);
|
|
|
|
const member = props.member;
|
|
|
|
// Convert date fields to proper format for input[type="date"]
|
|
const formatDateForInput = (dateString: string) => {
|
|
if (!dateString) return '';
|
|
try {
|
|
const date = new Date(dateString);
|
|
return date.toISOString().split('T')[0];
|
|
} catch {
|
|
return dateString;
|
|
}
|
|
};
|
|
|
|
form.value = {
|
|
first_name: member.first_name || '',
|
|
last_name: member.last_name || '',
|
|
email: member.email || '',
|
|
phone: member.phone || '',
|
|
date_of_birth: formatDateForInput(member.date_of_birth || ''),
|
|
nationality: member.nationality || '',
|
|
address: member.address || '',
|
|
membership_status: member.membership_status || 'Active',
|
|
member_since: formatDateForInput(member.member_since || ''),
|
|
current_year_dues_paid: member.current_year_dues_paid || 'false',
|
|
membership_date_paid: formatDateForInput(member.membership_date_paid || ''),
|
|
payment_due_date: formatDateForInput(member.payment_due_date || ''),
|
|
portal_group: member.portal_group || 'user'
|
|
};
|
|
|
|
// Set dues paid switch based on the string value
|
|
duesPaid.value = member.current_year_dues_paid === 'true';
|
|
|
|
console.log('[EditMemberDialog] Form populated:', form.value);
|
|
};
|
|
|
|
// Form submission
|
|
const handleSubmit = async () => {
|
|
if (!formRef.value || !props.member) 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('[EditMemberDialog] Updating member data:', memberData);
|
|
|
|
const response = await $fetch<{ success: boolean; data: Member; message?: string }>(`/api/members/${props.member.Id}`, {
|
|
method: 'PUT',
|
|
body: memberData
|
|
});
|
|
|
|
if (response.success && response.data) {
|
|
console.log('[EditMemberDialog] Member updated successfully:', response.data);
|
|
emit('member-updated', response.data);
|
|
closeDialog();
|
|
} else {
|
|
throw new Error(response.message || 'Failed to update member');
|
|
}
|
|
} catch (error: any) {
|
|
console.error('[EditMemberDialog] Error updating 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 update member. Please try again.'
|
|
};
|
|
}
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
};
|
|
|
|
// Dialog management
|
|
const closeDialog = () => {
|
|
emit('update:model-value', false);
|
|
};
|
|
|
|
// Watch for dialog open/close and member changes
|
|
watch(() => props.modelValue, (newValue) => {
|
|
if (newValue && props.member) {
|
|
// Dialog opened - populate form with member data
|
|
populateForm();
|
|
clearFieldErrors();
|
|
|
|
// Reset form validation
|
|
nextTick(() => {
|
|
formRef.value?.resetValidation();
|
|
});
|
|
}
|
|
});
|
|
|
|
watch(() => props.member, (newMember) => {
|
|
if (newMember && props.modelValue) {
|
|
// Member changed while dialog is open
|
|
populateForm();
|
|
}
|
|
});
|
|
|
|
// Lightbox functionality
|
|
const openImageLightbox = async () => {
|
|
if (!props.member?.member_id) return;
|
|
|
|
try {
|
|
// Fetch the original sized image for the lightbox
|
|
const response = await $fetch(`/api/profile/image/${props.member.member_id}/medium`) as any;
|
|
if (response?.success && response?.imageUrl) {
|
|
lightboxImageUrl.value = response.imageUrl;
|
|
showImageLightbox.value = true;
|
|
}
|
|
} catch (error) {
|
|
console.warn('Could not load image for lightbox:', error);
|
|
// Could show a snackbar here if needed
|
|
}
|
|
};
|
|
</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>
|