Merge branch 'main' of https://code.portnimara.com/ron/client-portal
This commit is contained in:
commit
fca6321dcf
|
|
@ -31,7 +31,7 @@
|
||||||
<!-- Generate EOI Button - Only show if no documents uploaded -->
|
<!-- Generate EOI Button - Only show if no documents uploaded -->
|
||||||
<div v-if="!hasEOI && !hasEOIDocuments" class="d-flex flex-wrap gap-2">
|
<div v-if="!hasEOI && !hasEOIDocuments" class="d-flex flex-wrap gap-2">
|
||||||
<v-btn
|
<v-btn
|
||||||
@click="generateEOI(0, false)"
|
@click="generateEOI"
|
||||||
:loading="isGenerating"
|
:loading="isGenerating"
|
||||||
color="primary"
|
color="primary"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
|
|
@ -84,22 +84,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Signature Links - Only show if EOI is generated but not uploaded/signed -->
|
<!-- Signature Links - Only show if EOI is generated but not uploaded/signed -->
|
||||||
<div v-if="hasEOI && !isEOISigned">
|
<v-list v-if="hasEOI && !isEOISigned" :density="mobile ? 'compact' : 'comfortable'">
|
||||||
<div class="d-flex align-center mb-3">
|
|
||||||
<v-chip color="info" variant="tonal" size="small" prepend-icon="mdi-account-multiple">
|
|
||||||
Signature Status
|
|
||||||
</v-chip>
|
|
||||||
<v-btn
|
|
||||||
icon="mdi-refresh"
|
|
||||||
variant="text"
|
|
||||||
size="small"
|
|
||||||
:loading="isCheckingStatus"
|
|
||||||
@click="checkSignatureStatus"
|
|
||||||
class="ml-2"
|
|
||||||
></v-btn>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<v-list :density="mobile ? 'compact' : 'comfortable'">
|
|
||||||
<v-list-item class="mb-2">
|
<v-list-item class="mb-2">
|
||||||
<template v-slot:prepend>
|
<template v-slot:prepend>
|
||||||
<v-avatar color="primary" :size="mobile ? 32 : 40">
|
<v-avatar color="primary" :size="mobile ? 32 : 40">
|
||||||
|
|
@ -109,22 +94,12 @@
|
||||||
<v-list-item-title :class="mobile ? 'text-body-2' : ''">Client Signature Link</v-list-item-title>
|
<v-list-item-title :class="mobile ? 'text-body-2' : ''">Client Signature Link</v-list-item-title>
|
||||||
<v-list-item-subtitle :class="mobile ? 'text-caption' : ''">{{ interest['Full Name'] }}</v-list-item-subtitle>
|
<v-list-item-subtitle :class="mobile ? 'text-caption' : ''">{{ interest['Full Name'] }}</v-list-item-subtitle>
|
||||||
<template v-slot:append>
|
<template v-slot:append>
|
||||||
<div class="d-flex align-center">
|
|
||||||
<v-icon
|
|
||||||
v-if="getSignatureStatusIcon('client')"
|
|
||||||
:color="getSignatureStatusColor('client')"
|
|
||||||
:size="mobile ? 'small' : 'default'"
|
|
||||||
class="mr-2"
|
|
||||||
>
|
|
||||||
{{ getSignatureStatusIcon('client') }}
|
|
||||||
</v-icon>
|
|
||||||
<v-btn
|
<v-btn
|
||||||
icon="mdi-content-copy"
|
icon="mdi-content-copy"
|
||||||
variant="text"
|
variant="text"
|
||||||
:size="mobile ? 'small' : 'default'"
|
:size="mobile ? 'small' : 'default'"
|
||||||
@click="copyLink(interest['Signature Link Client'])"
|
@click="copyLink(interest['Signature Link Client'])"
|
||||||
></v-btn>
|
></v-btn>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
|
|
||||||
|
|
@ -137,22 +112,12 @@
|
||||||
<v-list-item-title :class="mobile ? 'text-body-2' : ''">CC Signature Link</v-list-item-title>
|
<v-list-item-title :class="mobile ? 'text-body-2' : ''">CC Signature Link</v-list-item-title>
|
||||||
<v-list-item-subtitle :class="mobile ? 'text-caption' : ''">Oscar Faragher</v-list-item-subtitle>
|
<v-list-item-subtitle :class="mobile ? 'text-caption' : ''">Oscar Faragher</v-list-item-subtitle>
|
||||||
<template v-slot:append>
|
<template v-slot:append>
|
||||||
<div class="d-flex align-center">
|
|
||||||
<v-icon
|
|
||||||
v-if="getSignatureStatusIcon('cc')"
|
|
||||||
:color="getSignatureStatusColor('cc')"
|
|
||||||
:size="mobile ? 'small' : 'default'"
|
|
||||||
class="mr-2"
|
|
||||||
>
|
|
||||||
{{ getSignatureStatusIcon('cc') }}
|
|
||||||
</v-icon>
|
|
||||||
<v-btn
|
<v-btn
|
||||||
icon="mdi-content-copy"
|
icon="mdi-content-copy"
|
||||||
variant="text"
|
variant="text"
|
||||||
:size="mobile ? 'small' : 'default'"
|
:size="mobile ? 'small' : 'default'"
|
||||||
@click="copyLink(interest['Signature Link CC'])"
|
@click="copyLink(interest['Signature Link CC'])"
|
||||||
></v-btn>
|
></v-btn>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
|
|
||||||
|
|
@ -165,31 +130,20 @@
|
||||||
<v-list-item-title :class="mobile ? 'text-body-2' : ''">Developer Signature Link</v-list-item-title>
|
<v-list-item-title :class="mobile ? 'text-body-2' : ''">Developer Signature Link</v-list-item-title>
|
||||||
<v-list-item-subtitle :class="mobile ? 'text-caption' : ''">David Mizrahi</v-list-item-subtitle>
|
<v-list-item-subtitle :class="mobile ? 'text-caption' : ''">David Mizrahi</v-list-item-subtitle>
|
||||||
<template v-slot:append>
|
<template v-slot:append>
|
||||||
<div class="d-flex align-center">
|
|
||||||
<v-icon
|
|
||||||
v-if="getSignatureStatusIcon('developer')"
|
|
||||||
:color="getSignatureStatusColor('developer')"
|
|
||||||
:size="mobile ? 'small' : 'default'"
|
|
||||||
class="mr-2"
|
|
||||||
>
|
|
||||||
{{ getSignatureStatusIcon('developer') }}
|
|
||||||
</v-icon>
|
|
||||||
<v-btn
|
<v-btn
|
||||||
icon="mdi-content-copy"
|
icon="mdi-content-copy"
|
||||||
variant="text"
|
variant="text"
|
||||||
:size="mobile ? 'small' : 'default'"
|
:size="mobile ? 'small' : 'default'"
|
||||||
@click="copyLink(interest['Signature Link Developer'])"
|
@click="copyLink(interest['Signature Link Developer'])"
|
||||||
></v-btn>
|
></v-btn>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
</v-list>
|
</v-list>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Regenerate and Delete Generated EOI Buttons - Only show if EOI is generated but not uploaded/signed -->
|
<!-- Regenerate Button - Only show if EOI is generated but not uploaded/signed -->
|
||||||
<div v-if="hasEOI && !isEOISigned" class="mt-4 d-flex flex-wrap gap-2">
|
<div v-if="hasEOI && !isEOISigned" class="mt-4">
|
||||||
<v-btn
|
<v-btn
|
||||||
@click="generateEOI(0, true)"
|
@click="generateEOI"
|
||||||
:loading="isGenerating"
|
:loading="isGenerating"
|
||||||
variant="text"
|
variant="text"
|
||||||
size="small"
|
size="small"
|
||||||
|
|
@ -197,17 +151,6 @@
|
||||||
>
|
>
|
||||||
Regenerate EOI
|
Regenerate EOI
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
|
||||||
<v-btn
|
|
||||||
v-if="canDeleteGenerated"
|
|
||||||
@click="showDeleteGeneratedDialog = true"
|
|
||||||
color="error"
|
|
||||||
variant="outlined"
|
|
||||||
size="small"
|
|
||||||
prepend-icon="mdi-delete"
|
|
||||||
>
|
|
||||||
Delete Generated EOI
|
|
||||||
</v-btn>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Delete EOI Button - Only show if EOI is uploaded/signed -->
|
<!-- Delete EOI Button - Only show if EOI is uploaded/signed -->
|
||||||
|
|
@ -331,103 +274,6 @@
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-dialog>
|
</v-dialog>
|
||||||
|
|
||||||
<!-- Delete Generated EOI Dialog with Slider Confirmation -->
|
|
||||||
<v-dialog
|
|
||||||
v-model="showDeleteGeneratedDialog"
|
|
||||||
:max-width="mobile ? '100%' : '500'"
|
|
||||||
:transition="mobile ? 'dialog-bottom-transition' : 'dialog-transition'"
|
|
||||||
persistent
|
|
||||||
>
|
|
||||||
<v-card>
|
|
||||||
<v-card-title class="d-flex align-center">
|
|
||||||
<v-icon class="mr-2" color="error">mdi-delete-alert</v-icon>
|
|
||||||
Delete Generated EOI Document
|
|
||||||
</v-card-title>
|
|
||||||
|
|
||||||
<v-card-text>
|
|
||||||
<div class="mb-4">
|
|
||||||
<v-alert type="warning" variant="tonal" class="mb-4">
|
|
||||||
<v-icon>mdi-alert-triangle</v-icon>
|
|
||||||
This will permanently delete the generated EOI document from Documenso.
|
|
||||||
</v-alert>
|
|
||||||
|
|
||||||
This action will:
|
|
||||||
<ul class="mt-2 mb-4">
|
|
||||||
<li>Delete the document from Documenso platform</li>
|
|
||||||
<li>Remove all signature links</li>
|
|
||||||
<li>Reset the Sales Process Level to "Specific Qualified Interest"</li>
|
|
||||||
<li>Reset the EOI Status to "Awaiting Further Details"</li>
|
|
||||||
<li>Reset the EOI Time Sent field</li>
|
|
||||||
<li>Allow a new EOI to be generated</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div v-if="signatureStatus?.allSigned" class="text-error mb-3">
|
|
||||||
<v-icon class="mr-1">mdi-block-helper</v-icon>
|
|
||||||
Cannot delete: All parties have already signed this document.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="signatureStatus && (signatureStatus.signedRecipients?.length > 0)" class="text-warning mb-3">
|
|
||||||
<v-icon class="mr-1">mdi-alert</v-icon>
|
|
||||||
Warning: Some parties have already signed this document.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="!signatureStatus?.allSigned">
|
|
||||||
<v-divider class="mb-4" />
|
|
||||||
|
|
||||||
<div class="text-subtitle-2 mb-3">
|
|
||||||
To confirm deletion, slide the handle all the way to the right:
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="position-relative">
|
|
||||||
<v-slider
|
|
||||||
v-model="deleteSliderValue"
|
|
||||||
:max="100"
|
|
||||||
:min="0"
|
|
||||||
color="error"
|
|
||||||
track-color="grey-lighten-2"
|
|
||||||
thumb-label="always"
|
|
||||||
class="mt-2"
|
|
||||||
:disabled="isDeletingGenerated"
|
|
||||||
>
|
|
||||||
<template v-slot:thumb-label="{ modelValue }">
|
|
||||||
{{ Math.round(modelValue) }}%
|
|
||||||
</template>
|
|
||||||
</v-slider>
|
|
||||||
|
|
||||||
<div class="d-flex justify-space-between text-caption mt-1">
|
|
||||||
<span>Cancel</span>
|
|
||||||
<span :class="deleteSliderValue >= 100 ? 'text-error font-weight-bold' : 'text-grey'">
|
|
||||||
DELETE
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</v-card-text>
|
|
||||||
|
|
||||||
<v-card-actions>
|
|
||||||
<v-spacer />
|
|
||||||
<v-btn
|
|
||||||
@click="closeDeleteGeneratedDialog"
|
|
||||||
variant="text"
|
|
||||||
:disabled="isDeletingGenerated"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</v-btn>
|
|
||||||
<v-btn
|
|
||||||
color="error"
|
|
||||||
variant="flat"
|
|
||||||
@click="deleteGeneratedEOI"
|
|
||||||
:loading="isDeletingGenerated"
|
|
||||||
:disabled="deleteSliderValue < 100 || signatureStatus?.allSigned"
|
|
||||||
>
|
|
||||||
<v-icon class="mr-1">mdi-delete</v-icon>
|
|
||||||
Delete Generated EOI
|
|
||||||
</v-btn>
|
|
||||||
</v-card-actions>
|
|
||||||
</v-card>
|
|
||||||
</v-dialog>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -451,11 +297,6 @@ const isUploading = ref(false);
|
||||||
const selectedFile = ref<File | null>(null);
|
const selectedFile = ref<File | null>(null);
|
||||||
const showDeleteConfirmDialog = ref(false);
|
const showDeleteConfirmDialog = ref(false);
|
||||||
const isDeleting = ref(false);
|
const isDeleting = ref(false);
|
||||||
const signatureStatus = ref<any>(null);
|
|
||||||
const isCheckingStatus = ref(false);
|
|
||||||
const showDeleteGeneratedDialog = ref(false);
|
|
||||||
const isDeletingGenerated = ref(false);
|
|
||||||
const deleteSliderValue = ref(0);
|
|
||||||
|
|
||||||
const hasEOI = computed(() => {
|
const hasEOI = computed(() => {
|
||||||
return !!(props.interest['Signature Link Client'] ||
|
return !!(props.interest['Signature Link Client'] ||
|
||||||
|
|
@ -475,42 +316,7 @@ const isEOISigned = computed(() => {
|
||||||
return props.interest['EOI Status'] === 'Signed';
|
return props.interest['EOI Status'] === 'Signed';
|
||||||
});
|
});
|
||||||
|
|
||||||
const canDeleteGenerated = computed(() => {
|
const generateEOI = async (retryCount = 0) => {
|
||||||
return hasEOI.value && !isEOISigned.value && (!signatureStatus.value?.allSigned);
|
|
||||||
});
|
|
||||||
|
|
||||||
const checkSignatureStatus = async () => {
|
|
||||||
if (!hasEOI.value || isEOISigned.value) return;
|
|
||||||
|
|
||||||
isCheckingStatus.value = true;
|
|
||||||
try {
|
|
||||||
const response = await $fetch<{
|
|
||||||
success: boolean;
|
|
||||||
documentStatus: string;
|
|
||||||
unsignedRecipients: any[];
|
|
||||||
signedRecipients: any[];
|
|
||||||
clientSigned: boolean;
|
|
||||||
allSigned: boolean;
|
|
||||||
}>('/api/eoi/check-signature-status', {
|
|
||||||
headers: {
|
|
||||||
'x-tag': '094ut234'
|
|
||||||
},
|
|
||||||
params: {
|
|
||||||
interestId: props.interest.Id.toString()
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.success) {
|
|
||||||
signatureStatus.value = response;
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Failed to check signature status:', error);
|
|
||||||
} finally {
|
|
||||||
isCheckingStatus.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const generateEOI = async (retryCount = 0, regenerate = false) => {
|
|
||||||
isGenerating.value = true;
|
isGenerating.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -525,16 +331,13 @@ const generateEOI = async (retryCount = 0, regenerate = false) => {
|
||||||
'x-tag': '094ut234'
|
'x-tag': '094ut234'
|
||||||
},
|
},
|
||||||
body: {
|
body: {
|
||||||
interestId: props.interest.Id.toString(),
|
interestId: props.interest.Id.toString()
|
||||||
regenerate: regenerate
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
toast.success(response.documentId === 'existing'
|
toast.success(response.documentId === 'existing'
|
||||||
? 'EOI already exists - signature links retrieved'
|
? 'EOI already exists - signature links retrieved'
|
||||||
: regenerate
|
|
||||||
? 'EOI regenerated successfully'
|
|
||||||
: 'EOI generated successfully');
|
: 'EOI generated successfully');
|
||||||
|
|
||||||
emit('eoi-generated', { signingLinks: response.signingLinks });
|
emit('eoi-generated', { signingLinks: response.signingLinks });
|
||||||
|
|
@ -549,7 +352,7 @@ const generateEOI = async (retryCount = 0, regenerate = false) => {
|
||||||
if (retryCount < 3) {
|
if (retryCount < 3) {
|
||||||
console.log(`Retrying EOI generation... Attempt ${retryCount + 2}/4`);
|
console.log(`Retrying EOI generation... Attempt ${retryCount + 2}/4`);
|
||||||
await new Promise(resolve => setTimeout(resolve, (retryCount + 1) * 1000));
|
await new Promise(resolve => setTimeout(resolve, (retryCount + 1) * 1000));
|
||||||
return generateEOI(retryCount + 1, regenerate);
|
return generateEOI(retryCount + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show error message after all retries failed
|
// Show error message after all retries failed
|
||||||
|
|
@ -670,6 +473,7 @@ const handleUpload = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const closeUploadDialog = () => {
|
const closeUploadDialog = () => {
|
||||||
showUploadDialog.value = false;
|
showUploadDialog.value = false;
|
||||||
selectedFile.value = null;
|
selectedFile.value = null;
|
||||||
|
|
@ -701,94 +505,4 @@ const deleteEOI = async () => {
|
||||||
isDeleting.value = false;
|
isDeleting.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSignatureStatusIcon = (recipientType: 'client' | 'cc' | 'developer') => {
|
|
||||||
if (!signatureStatus.value) return null;
|
|
||||||
|
|
||||||
const signedRecipients = signatureStatus.value.signedRecipients || [];
|
|
||||||
const unsignedRecipients = signatureStatus.value.unsignedRecipients || [];
|
|
||||||
|
|
||||||
// Check if this recipient type has signed
|
|
||||||
let isSigned = false;
|
|
||||||
|
|
||||||
if (recipientType === 'client') {
|
|
||||||
isSigned = signatureStatus.value.clientSigned || false;
|
|
||||||
} else {
|
|
||||||
// For CC and Developer, check by email
|
|
||||||
const emailMap = {
|
|
||||||
cc: 'sales@portnimara.com',
|
|
||||||
developer: 'dm@portnimara.com'
|
|
||||||
};
|
|
||||||
|
|
||||||
const email = emailMap[recipientType];
|
|
||||||
isSigned = signedRecipients.some((r: any) => r.email === email);
|
|
||||||
}
|
|
||||||
|
|
||||||
return isSigned ? 'mdi-check-circle' : 'mdi-clock-outline';
|
|
||||||
};
|
|
||||||
|
|
||||||
const getSignatureStatusColor = (recipientType: 'client' | 'cc' | 'developer') => {
|
|
||||||
if (!signatureStatus.value) return 'grey';
|
|
||||||
|
|
||||||
const signedRecipients = signatureStatus.value.signedRecipients || [];
|
|
||||||
|
|
||||||
// Check if this recipient type has signed
|
|
||||||
let isSigned = false;
|
|
||||||
|
|
||||||
if (recipientType === 'client') {
|
|
||||||
isSigned = signatureStatus.value.clientSigned || false;
|
|
||||||
} else {
|
|
||||||
// For CC and Developer, check by email
|
|
||||||
const emailMap = {
|
|
||||||
cc: 'sales@portnimara.com',
|
|
||||||
developer: 'dm@portnimara.com'
|
|
||||||
};
|
|
||||||
|
|
||||||
const email = emailMap[recipientType];
|
|
||||||
isSigned = signedRecipients.some((r: any) => r.email === email);
|
|
||||||
}
|
|
||||||
|
|
||||||
return isSigned ? 'success' : 'warning';
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeDeleteGeneratedDialog = () => {
|
|
||||||
showDeleteGeneratedDialog.value = false;
|
|
||||||
deleteSliderValue.value = 0; // Reset slider
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteGeneratedEOI = async () => {
|
|
||||||
if (deleteSliderValue.value < 100) return;
|
|
||||||
|
|
||||||
isDeletingGenerated.value = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await $fetch<{ success: boolean; message: string }>('/api/eoi/delete-generated-document', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'x-tag': '094ut234'
|
|
||||||
},
|
|
||||||
body: {
|
|
||||||
interestId: props.interest.Id.toString()
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.success) {
|
|
||||||
toast.success('Generated EOI deleted successfully');
|
|
||||||
closeDeleteGeneratedDialog();
|
|
||||||
emit('update'); // Refresh parent data
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Failed to delete generated EOI:', error);
|
|
||||||
toast.error(error.data?.statusMessage || 'Failed to delete generated EOI');
|
|
||||||
} finally {
|
|
||||||
isDeletingGenerated.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check signature status when component mounts and when interest changes
|
|
||||||
watchEffect(() => {
|
|
||||||
if (hasEOI.value && !isEOISigned.value) {
|
|
||||||
checkSignatureStatus();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -139,14 +139,25 @@ const getAttachmentName = (attachment: any) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const getAttachmentUrl = (attachment: any) => {
|
const getAttachmentUrl = (attachment: any) => {
|
||||||
// If attachment has a path and bucket, construct the download URL
|
// If attachment is just a string (filename), assume it's in the client-emails bucket
|
||||||
if (attachment.path && attachment.bucket) {
|
if (typeof attachment === 'string') {
|
||||||
return `/api/files/proxy-download?path=${encodeURIComponent(attachment.path)}&bucket=${attachment.bucket}`;
|
return `/api/files/proxy-download?fileName=${encodeURIComponent(attachment)}&bucket=client-emails`;
|
||||||
}
|
}
|
||||||
// If it's just a URL, return it
|
|
||||||
if (attachment.url) return attachment.url;
|
// If it has a path property, use that
|
||||||
// Otherwise return a placeholder
|
if (attachment?.path) {
|
||||||
return '#';
|
const bucket = attachment.bucket || 'client-emails';
|
||||||
|
return `/api/files/proxy-download?fileName=${encodeURIComponent(attachment.path)}&bucket=${bucket}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it has a url property, use that directly
|
||||||
|
if (attachment?.url) {
|
||||||
|
return attachment.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default fallback
|
||||||
|
const name = attachment?.name || attachment?.filename || 'attachment';
|
||||||
|
return `/api/files/proxy-download?fileName=${encodeURIComponent(name)}&bucket=client-emails`;
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -196,7 +196,7 @@
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col cols="12">
|
<v-col cols="12">
|
||||||
<v-btn
|
<v-btn
|
||||||
@click="() => debouncedSaveInterest ? debouncedSaveInterest() : saveInterest()"
|
@click="handleMobileSave"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
color="success"
|
color="success"
|
||||||
block
|
block
|
||||||
|
|
@ -407,46 +407,8 @@
|
||||||
prepend-inner-icon="mdi-tag"
|
prepend-inner-icon="mdi-tag"
|
||||||
></v-select>
|
></v-select>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col cols="12" md="6">
|
<!-- Berth Recommendations field hidden per user request -->
|
||||||
<v-autocomplete
|
<v-col cols="12" md="12">
|
||||||
v-model="selectedBerthRecommendations"
|
|
||||||
:items="groupedBerths"
|
|
||||||
:item-title="(item) => item.isDivider ? '' : item['Mooring Number']"
|
|
||||||
:item-value="(item) => item.isDivider ? null : item.Id"
|
|
||||||
label="Berth Recommendations"
|
|
||||||
variant="outlined"
|
|
||||||
density="comfortable"
|
|
||||||
multiple
|
|
||||||
chips
|
|
||||||
closable-chips
|
|
||||||
:loading="loadingBerths"
|
|
||||||
prepend-inner-icon="mdi-star"
|
|
||||||
@update:model-value="updateBerthRecommendations"
|
|
||||||
>
|
|
||||||
<template v-slot:item="{ props, item }">
|
|
||||||
<v-divider v-if="item.raw.isDivider" class="mt-2 mb-2">
|
|
||||||
<template v-slot:default>
|
|
||||||
<div class="text-caption text-medium-emphasis px-2">
|
|
||||||
{{ item.raw.letter }}
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</v-divider>
|
|
||||||
<v-list-item
|
|
||||||
v-else
|
|
||||||
v-bind="props"
|
|
||||||
:title="item.raw['Mooring Number']"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<template v-slot:chip="{ props, item }">
|
|
||||||
<v-chip
|
|
||||||
v-bind="props"
|
|
||||||
:text="item.raw['Mooring Number']"
|
|
||||||
size="small"
|
|
||||||
></v-chip>
|
|
||||||
</template>
|
|
||||||
</v-autocomplete>
|
|
||||||
</v-col>
|
|
||||||
<v-col cols="12" md="6">
|
|
||||||
<v-autocomplete
|
<v-autocomplete
|
||||||
v-model="selectedBerths"
|
v-model="selectedBerths"
|
||||||
:items="groupedBerths"
|
:items="groupedBerths"
|
||||||
|
|
@ -1094,9 +1056,18 @@ const updateBerths = async (newBerths: number[]) => {
|
||||||
|
|
||||||
// Update original values
|
// Update original values
|
||||||
originalBerths.value = [...newBerths];
|
originalBerths.value = [...newBerths];
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
if (toAdd.length > 0 || toRemove.length > 0) {
|
||||||
|
toast.success("Berths updated successfully");
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update berths:", error);
|
console.error("Failed to update berths:", error);
|
||||||
toast.error("Failed to update berths. Please try again.");
|
// Show more specific error message on mobile
|
||||||
|
const errorMessage = mobile.value
|
||||||
|
? "Could not update berths. Please check your connection and try again."
|
||||||
|
: "Failed to update berths. Please try again.";
|
||||||
|
toast.error(errorMessage);
|
||||||
// Revert to original values on error
|
// Revert to original values on error
|
||||||
selectedBerths.value = [...originalBerths.value];
|
selectedBerths.value = [...originalBerths.value];
|
||||||
}
|
}
|
||||||
|
|
@ -1302,6 +1273,20 @@ const onInterestUpdated = async () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handle mobile save - call the save function directly without debounce
|
||||||
|
const handleMobileSave = () => {
|
||||||
|
console.log('Mobile save button clicked');
|
||||||
|
// Cancel any pending debounced saves
|
||||||
|
if (debouncedSaveInterest) {
|
||||||
|
debouncedSaveInterest.cancel();
|
||||||
|
}
|
||||||
|
if (autoSave) {
|
||||||
|
autoSave.cancel();
|
||||||
|
}
|
||||||
|
// Call save directly
|
||||||
|
saveInterest();
|
||||||
|
};
|
||||||
|
|
||||||
// Load berths when component mounts
|
// Load berths when component mounts
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadAvailableBerths();
|
loadAvailableBerths();
|
||||||
|
|
|
||||||
|
|
@ -125,7 +125,7 @@
|
||||||
:headers="headers"
|
:headers="headers"
|
||||||
:items="filteredInterests"
|
:items="filteredInterests"
|
||||||
:search="search"
|
:search="search"
|
||||||
:sort-by="[{ key: 'Created At', order: 'desc' }, { key: 'Full Name', order: 'asc' }]"
|
:sort-by="[{ key: 'Id', order: 'desc' }]"
|
||||||
must-sort
|
must-sort
|
||||||
hover
|
hover
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ interface EmailMessage {
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
direction: 'sent' | 'received';
|
direction: 'sent' | 'received';
|
||||||
threadId?: string;
|
threadId?: string;
|
||||||
|
attachments?: any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
|
|
@ -304,6 +305,15 @@ async function fetchImapEmails(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract attachments
|
||||||
|
const attachments = parsed.attachments ? parsed.attachments.map((att: any) => ({
|
||||||
|
filename: att.filename || 'attachment',
|
||||||
|
name: att.filename || 'attachment',
|
||||||
|
size: att.size || 0,
|
||||||
|
type: att.contentType || 'application/octet-stream',
|
||||||
|
cid: att.cid || undefined
|
||||||
|
})) : [];
|
||||||
|
|
||||||
const email: EmailMessage = {
|
const email: EmailMessage = {
|
||||||
id: parsed.messageId || `${Date.now()}-${seqno}`,
|
id: parsed.messageId || `${Date.now()}-${seqno}`,
|
||||||
from: fromEmail,
|
from: fromEmail,
|
||||||
|
|
@ -312,7 +322,8 @@ async function fetchImapEmails(
|
||||||
body: parsed.text || '',
|
body: parsed.text || '',
|
||||||
html: parsed.html || undefined,
|
html: parsed.html || undefined,
|
||||||
timestamp: parsed.date?.toISOString() || new Date().toISOString(),
|
timestamp: parsed.date?.toISOString() || new Date().toISOString(),
|
||||||
direction: fromEmail.toLowerCase().includes(userEmail.toLowerCase()) ? 'sent' : 'received'
|
direction: fromEmail.toLowerCase().includes(userEmail.toLowerCase()) ? 'sent' : 'received',
|
||||||
|
attachments: attachments
|
||||||
};
|
};
|
||||||
|
|
||||||
if (parsed.headers.has('in-reply-to')) {
|
if (parsed.headers.has('in-reply-to')) {
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,9 @@ export default defineEventHandler(async (event) => {
|
||||||
throw createError({ statusCode: 401, statusMessage: "unauthenticated" });
|
throw createError({ statusCode: 401, statusMessage: "unauthenticated" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set longer timeout for this endpoint to prevent 502 errors
|
||||||
|
event.node.res.setTimeout(60000); // 60 seconds
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await readBody(event);
|
const body = await readBody(event);
|
||||||
const { interestId } = body;
|
const { interestId } = body;
|
||||||
|
|
|
||||||
|
|
@ -181,7 +181,8 @@ export default defineEventHandler(async (event) => {
|
||||||
html: htmlBody,
|
html: htmlBody,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
direction: 'sent',
|
direction: 'sent',
|
||||||
interestId: interestId
|
interestId: interestId,
|
||||||
|
attachments: attachments // Include attachment info
|
||||||
};
|
};
|
||||||
|
|
||||||
const objectName = `interest-${interestId}/${Date.now()}-sent.json`;
|
const objectName = `interest-${interestId}/${Date.now()}-sent.json`;
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,7 @@ export const createInterest = async (data: Partial<Interest>) => {
|
||||||
"Date Added",
|
"Date Added",
|
||||||
"Width",
|
"Width",
|
||||||
"Depth",
|
"Depth",
|
||||||
|
"Created At",
|
||||||
"Source",
|
"Source",
|
||||||
"Contact Method Preferred",
|
"Contact Method Preferred",
|
||||||
"Lead Category",
|
"Lead Category",
|
||||||
|
|
@ -213,6 +214,11 @@ export const createInterest = async (data: Partial<Interest>) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set Created At to current timestamp if not provided
|
||||||
|
if (!cleanData["Created At"]) {
|
||||||
|
cleanData["Created At"] = new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
// Remove any computed or relation fields that shouldn't be sent
|
// Remove any computed or relation fields that shouldn't be sent
|
||||||
delete cleanData.Id;
|
delete cleanData.Id;
|
||||||
delete cleanData.Berths;
|
delete cleanData.Berths;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue