Refactor EOI management into dedicated component

Extract EOI links and generation functionality from InterestDetailsModal
into a new reusable EOISection component. This improves code organization
and maintainability while adding debounce support for form submissions.

- Create new EOISection.vue component for EOI management
- Remove inline EOI links section from InterestDetailsModal
- Add debounce utility for form submission handling
- Update email generation and thread fetching logic
- Update related types and utilities
This commit is contained in:
2025-06-10 00:37:43 +02:00
parent 76d04a1e2a
commit d9fb94a76c
9 changed files with 356 additions and 88 deletions

177
components/EOISection.vue Normal file
View File

@@ -0,0 +1,177 @@
<template>
<div class="border rounded-lg p-4 bg-gray-50">
<h3 class="text-lg font-semibold mb-4">EOI Management</h3>
<!-- Generate EOI Button -->
<div v-if="!hasEOI" class="mb-4">
<button
@click="generateEOI"
:disabled="isGenerating"
class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{{ isGenerating ? 'Generating EOI...' : 'Generate EOI' }}
</button>
</div>
<!-- EOI Status Badge -->
<div v-if="hasEOI" class="mb-4">
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium"
:class="{
'bg-yellow-100 text-yellow-800': interest['EOI Status'] === 'Waiting for Signatures',
'bg-green-100 text-green-800': interest['EOI Status'] === 'Signed',
'bg-gray-100 text-gray-800': interest['EOI Status'] === 'Awaiting Further Details'
}">
{{ interest['EOI Status'] }}
</span>
<span v-if="interest['EOI Time Sent']" class="ml-2 text-sm text-gray-600">
Sent: {{ formatDate(interest['EOI Time Sent']) }}
</span>
</div>
<!-- Signature Links -->
<div v-if="hasEOI" class="space-y-3">
<div class="border rounded p-3 bg-white">
<div class="flex justify-between items-center">
<div>
<span class="font-medium">Client Signature Link</span>
<span class="text-sm text-gray-500 ml-2">({{ interest['Full Name'] }})</span>
</div>
<button
@click="copyLink(interest['Signature Link Client'])"
class="text-blue-600 hover:text-blue-800 text-sm flex items-center"
>
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
Copy
</button>
</div>
</div>
<div class="border rounded p-3 bg-white">
<div class="flex justify-between items-center">
<div>
<span class="font-medium">CC Signature Link</span>
<span class="text-sm text-gray-500 ml-2">(Oscar Faragher)</span>
</div>
<button
@click="copyLink(interest['Signature Link CC'])"
class="text-blue-600 hover:text-blue-800 text-sm flex items-center"
>
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
Copy
</button>
</div>
</div>
<div class="border rounded p-3 bg-white">
<div class="flex justify-between items-center">
<div>
<span class="font-medium">Developer Signature Link</span>
<span class="text-sm text-gray-500 ml-2">(David Mizrahi)</span>
</div>
<button
@click="copyLink(interest['Signature Link Developer'])"
class="text-blue-600 hover:text-blue-800 text-sm flex items-center"
>
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
Copy
</button>
</div>
</div>
</div>
<!-- Regenerate Button -->
<div v-if="hasEOI && interest['EOI Status'] !== 'Signed'" class="mt-4">
<button
@click="generateEOI"
:disabled="isGenerating"
class="text-sm text-gray-600 hover:text-gray-800 underline"
>
Regenerate EOI
</button>
</div>
</div>
</template>
<script setup lang="ts">
import type { Interest } from '~/utils/types';
const props = defineProps<{
interest: Interest;
}>();
const emit = defineEmits<{
'eoi-generated': [data: { signingLinks: Record<string, string> }];
'update': [];
}>();
const { showToast } = useToast();
const isGenerating = ref(false);
const hasEOI = computed(() => {
return !!(props.interest['Signature Link Client'] ||
props.interest['Signature Link CC'] ||
props.interest['Signature Link Developer']);
});
const generateEOI = async () => {
isGenerating.value = true;
try {
const response = await $fetch<{
success: boolean;
documentId: string | number;
clientSigningUrl: string;
signingLinks: Record<string, string>;
}>('/api/email/generate-eoi-document', {
method: 'POST',
headers: {
'x-tag': '094ut234'
},
body: {
interestId: props.interest.Id.toString()
}
});
if (response.success) {
showToast(response.documentId === 'existing'
? 'EOI already exists - signature links retrieved'
: 'EOI generated successfully');
emit('eoi-generated', { signingLinks: response.signingLinks });
emit('update'); // Trigger parent to refresh data
}
} catch (error: any) {
console.error('Failed to generate EOI:', error);
showToast(error.data?.statusMessage || 'Failed to generate EOI');
} finally {
isGenerating.value = false;
}
};
const copyLink = (link: string | undefined) => {
if (!link) return;
navigator.clipboard.writeText(link).then(() => {
showToast('Signature link copied to clipboard');
}).catch(() => {
showToast('Failed to copy link');
});
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
</script>

View File

@@ -244,7 +244,7 @@
</v-card-text>
</v-card>
<v-form @submit.prevent="saveInterest">
<v-form @submit.prevent="handleFormSubmit">
<!-- Contact Information Section -->
<v-card variant="flat" class="mb-6">
<v-card-title class="text-h6 d-flex align-center pb-4">
@@ -626,79 +626,14 @@
</v-card-text>
</v-card>
<!-- EOI Links Section (only shows if EOI has been sent) -->
<v-card
v-if="hasEOILinks"
variant="flat"
class="mb-6"
>
<v-card-title class="text-h6 d-flex align-center pb-4">
<v-icon class="mr-2" color="primary">mdi-link-variant</v-icon>
EOI Links
</v-card-title>
<v-card-text class="pt-2">
<v-list>
<v-list-item
v-if="(interest as any)['EOI Client Link']"
class="mb-2"
>
<template v-slot:prepend>
<v-avatar color="primary" size="40">
<v-icon>mdi-account</v-icon>
</v-avatar>
</template>
<v-list-item-title>Client ({{ interest['Full Name'] }})</v-list-item-title>
<v-list-item-subtitle class="text-truncate">{{ (interest as any)['EOI Client Link'] }}</v-list-item-subtitle>
<template v-slot:append>
<v-btn
icon="mdi-content-copy"
variant="text"
@click="copyToClipboard((interest as any)['EOI Client Link'], 'Client')"
></v-btn>
</template>
</v-list-item>
<v-list-item
v-if="(interest as any)['EOI Oscar Link']"
class="mb-2"
>
<template v-slot:prepend>
<v-avatar color="success" size="40">
<v-icon>mdi-account-check</v-icon>
</v-avatar>
</template>
<v-list-item-title>Oscar Faragher (Approver)</v-list-item-title>
<v-list-item-subtitle class="text-truncate">{{ (interest as any)['EOI Oscar Link'] }}</v-list-item-subtitle>
<template v-slot:append>
<v-btn
icon="mdi-content-copy"
variant="text"
@click="copyToClipboard((interest as any)['EOI Oscar Link'], 'Oscar Faragher')"
></v-btn>
</template>
</v-list-item>
<v-list-item
v-if="(interest as any)['EOI David Link']"
class="mb-2"
>
<template v-slot:prepend>
<v-avatar color="secondary" size="40">
<v-icon>mdi-account-tie</v-icon>
</v-avatar>
</template>
<v-list-item-title>David Mizrahi (Signer)</v-list-item-title>
<v-list-item-subtitle class="text-truncate">{{ (interest as any)['EOI David Link'] }}</v-list-item-subtitle>
<template v-slot:append>
<v-btn
icon="mdi-content-copy"
variant="text"
@click="copyToClipboard((interest as any)['EOI David Link'], 'David Mizrahi')"
></v-btn>
</template>
</v-list-item>
</v-list>
</v-card-text>
<!-- EOI Management Section -->
<v-card variant="flat" class="mb-6">
<EOISection
v-if="interest"
:interest="interest"
@eoi-generated="onEOIGenerated"
@update="onInterestUpdated"
/>
</v-card>
<!-- Email Communication Section -->
@@ -714,10 +649,30 @@
</template>
<script lang="ts" setup>
import { ref, computed, watch, onMounted } from "vue";
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
import type { Interest, Berth } from "@/utils/types";
// Simple debounce implementation
function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): ((...args: Parameters<T>) => void) & { cancel: () => void } {
let timeout: NodeJS.Timeout | null = null;
const debounced = (...args: Parameters<T>) => {
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
debounced.cancel = () => {
if (timeout) clearTimeout(timeout);
};
return debounced;
}
import PhoneInput from "./PhoneInput.vue";
import EmailCommunication from "./EmailCommunication.vue";
import EOISection from "./EOISection.vue";
import {
InterestSalesProcessLevelFlow,
InterestLeadCategoryFlow,
@@ -753,6 +708,10 @@ const toast = useToast();
// Local copy of the interest for editing
const interest = ref<Interest | null>(null);
// Auto-save related
const hasUnsavedChanges = ref(false);
const autoSaveTimer = ref<NodeJS.Timeout | null>(null);
// Loading states for buttons
const isSaving = ref(false);
const isRequestingMoreInfo = ref(false);
@@ -768,11 +727,32 @@ const selectedBerthRecommendations = ref<number[]>([]);
const originalBerths = ref<number[]>([]);
const originalBerthRecommendations = ref<number[]>([]);
// Auto-save function (debounced)
const autoSave = debounce(async () => {
if (!hasUnsavedChanges.value || !interest.value) return;
console.log('Auto-saving interest...');
await saveInterest(true); // Pass true to indicate auto-save
}, 2000); // 2 second delay
// Watch for changes to trigger auto-save
watch(
() => interest.value,
(newValue, oldValue) => {
if (newValue && oldValue && JSON.stringify(newValue) !== JSON.stringify(oldValue)) {
hasUnsavedChanges.value = true;
autoSave();
}
},
{ deep: true }
);
// Sync the local copy with the prop
watch(
() => props.selectedInterest,
async (newInterest) => {
if (newInterest) {
hasUnsavedChanges.value = false;
interest.value = { ...newInterest };
// Load linked berths and recommendations
await loadLinkedBerths();
@@ -812,7 +792,11 @@ const closeModal = () => {
isOpen.value = false;
};
const saveInterest = async () => {
const handleFormSubmit = () => {
saveInterest();
};
const saveInterest = async (isAutoSave = false) => {
if (interest.value) {
isSaving.value = true;
try {
@@ -833,12 +817,21 @@ const saveInterest = async () => {
},
});
toast.success("Interest saved successfully!");
emit("save", interest.value);
closeModal();
hasUnsavedChanges.value = false;
if (!isAutoSave) {
toast.success("Interest saved successfully!");
emit("save", interest.value);
closeModal();
} else {
// For auto-save, just emit save to refresh parent
emit("save", interest.value);
}
} catch (error) {
console.error("Failed to save interest:", error);
toast.error("Failed to save interest. Please try again.");
if (!isAutoSave) {
toast.error("Failed to save interest. Please try again.");
}
} finally {
isSaving.value = false;
}
@@ -1188,6 +1181,12 @@ const copyToClipboard = async (text: string, recipient: string) => {
}
};
// Handle EOI generated event
const onEOIGenerated = (data: { signingLinks: Record<string, string> }) => {
console.log('EOI generated with links:', data.signingLinks);
// The EOISection component will trigger the update event, so we just need to handle that
};
// Handle interest updated event from EmailCommunication
const onInterestUpdated = async () => {
// Reload the interest data
@@ -1216,4 +1215,13 @@ const onInterestUpdated = async () => {
onMounted(() => {
loadAvailableBerths();
});
// Cleanup on unmount
onUnmounted(() => {
if (autoSaveTimer.value) {
clearTimeout(autoSaveTimer.value);
}
// Cancel any pending auto-save
autoSave.cancel();
});
</script>