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:
177
components/EOISection.vue
Normal file
177
components/EOISection.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user