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:
Matt 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>

View File

@ -131,6 +131,37 @@ NUXT_DOCUMENSO_API_KEY=your-actual-api-key
NUXT_DOCUMENSO_BASE_URL=https://signatures.portnimara.dev
```
### 12. EOI Management System
- **Added EOI Section Component**: New component to manage EOI document generation and signature links
- **Features Implemented**:
- Generate EOI button that creates documents via Documenso API
- Checks for existing EOI before generating new one (prevents duplicates)
- Displays all 3 signature links (Client, CC, Developer) with copy-to-clipboard functionality
- Shows EOI status badge (Awaiting Further Details, Waiting for Signatures, Signed)
- Regenerate option for non-signed documents
- **Auto-Updates on EOI Generation**:
- EOI Status → "Waiting for Signatures"
- Sales Process Level → "LOI and NDA Sent"
- EOI Time Sent → Current timestamp
- Extra Comments → Appends "EOI Sent [timestamp]"
- **Database Storage**: Links stored in new columns:
- `Signature Link Client`
- `Signature Link CC`
- `Signature Link Developer`
### 13. Auto-Save Functionality
- **Implemented**: Interest details now auto-save after 2 seconds of inactivity
- **Features**:
- Debounced save to prevent excessive API calls
- Silent save without success notifications
- Automatically triggers parent refresh to keep data in sync
- Cancels pending saves on component unmount
### 14. IMAP BCC Search Fix
- **Problem**: IMAP search was failing with "Cannot read properties of null"
- **Cause**: BCC search criteria not supported by all IMAP servers
- **Solution**: Removed BCC from search criteria, now only searches FROM, TO, and CC fields
## How It Works Now
1. **Email Session Management**:

23
package-lock.json generated
View File

@ -6,9 +6,11 @@
"": {
"hasInstallScript": true,
"dependencies": {
"@types/lodash-es": "^4.17.12",
"@vite-pwa/nuxt": "^0.10.6",
"formidable": "^3.5.4",
"imap": "^0.8.19",
"lodash-es": "^4.17.21",
"mailparser": "^3.7.3",
"mime-types": "^3.0.1",
"minio": "^8.0.5",
@ -3710,6 +3712,21 @@
"@types/node": "*"
}
},
"node_modules/@types/lodash": {
"version": "4.17.17",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.17.tgz",
"integrity": "sha512-RRVJ+J3J+WmyOTqnz3PiBLA501eKwXl2noseKOrNo/6+XEHjTAxO4xHvxQB6QuNm+s4WRbn6rSiap8+EA+ykFQ==",
"license": "MIT"
},
"node_modules/@types/lodash-es": {
"version": "4.17.12",
"resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz",
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
"license": "MIT",
"dependencies": {
"@types/lodash": "*"
}
},
"node_modules/@types/mailparser": {
"version": "3.4.6",
"resolved": "https://registry.npmjs.org/@types/mailparser/-/mailparser-3.4.6.tgz",
@ -8821,6 +8838,12 @@
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
"node_modules/lodash-es": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
"license": "MIT"
},
"node_modules/lodash.debounce": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",

View File

@ -8,9 +8,11 @@
"postinstall": "nuxt prepare"
},
"dependencies": {
"@types/lodash-es": "^4.17.12",
"@vite-pwa/nuxt": "^0.10.6",
"formidable": "^3.5.4",
"imap": "^0.8.19",
"lodash-es": "^4.17.21",
"mailparser": "^3.7.3",
"mime-types": "^3.0.1",
"minio": "^8.0.5",

View File

@ -203,12 +203,12 @@ async function fetchImapEmails(
console.log(`Searching in folder: ${folderName}`);
// Search for emails both sent and received with this client
// Note: BCC search might not be supported by all IMAP servers
const searchCriteria = [
'OR',
['FROM', clientEmail],
['TO', clientEmail],
['CC', clientEmail],
['BCC', clientEmail]
['CC', clientEmail]
];
imap.search(searchCriteria, (err, results) => {

View File

@ -43,6 +43,21 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 404, statusMessage: "Interest not found" });
}
// Check if EOI already exists (has signature links)
if (interest['Signature Link Client'] && interest['Signature Link CC'] && interest['Signature Link Developer']) {
console.log('EOI already exists, returning existing links');
return {
success: true,
documentId: 'existing',
clientSigningUrl: interest['Signature Link Client'],
signingLinks: {
'Client': interest['Signature Link Client'],
'CC': interest['Signature Link CC'],
'Developer': interest['Signature Link Developer']
}
};
}
// Validate required fields
const requiredFields = [
{ field: 'Full Name', value: interest['Full Name'] },
@ -260,15 +275,15 @@ export default defineEventHandler(async (event) => {
'Extra Comments': updatedComments
};
// Add signing links to update data
// Add signing links to update data with new column names
if (signingLinks['Client']) {
updateData['EOI Client Link'] = signingLinks['Client'];
updateData['Signature Link Client'] = signingLinks['Client'];
}
if (signingLinks['David Mizrahi']) {
updateData['EOI David Link'] = signingLinks['David Mizrahi'];
updateData['Signature Link Developer'] = signingLinks['David Mizrahi'];
}
if (signingLinks['Oscar Faragher']) {
updateData['EOI Oscar Link'] = signingLinks['Oscar Faragher'];
updateData['Signature Link CC'] = signingLinks['Oscar Faragher'];
}
await updateInterest(interestId, updateData);

View File

@ -98,9 +98,13 @@ export const updateInterest = async (id: string, data: Partial<Interest>, retryC
"Contract Status",
// Add the EOI link fields
"EOI Client Link",
"EOI David Link",
"EOI David Link",
"EOI Oscar Link",
"EOI Document"
"EOI Document",
// Add the new signature link fields
"Signature Link Client",
"Signature Link CC",
"Signature Link Developer"
];
// Filter the data to only include allowed fields

View File

@ -119,6 +119,14 @@ export interface Interest {
"Contract Sent Status": ContractSentStatus;
"Deposit 10% Status": Deposit10PercentStatus;
"Contract Status": ContractStatus;
// EOI Link fields
"EOI Client Link"?: string;
"EOI David Link"?: string;
"EOI Oscar Link"?: string;
// New signature link fields
"Signature Link Client"?: string;
"Signature Link CC"?: string;
"Signature Link Developer"?: string;
}
export interface InterestsResponse {