This commit is contained in:
2025-06-11 14:08:28 +02:00
parent 0b6601fabc
commit f992fbb5a6
6 changed files with 717 additions and 85 deletions

View File

@@ -28,8 +28,8 @@
</div>
</div>
<!-- Generate EOI Button - Only show if no documents uploaded -->
<div v-if="!hasEOI && !hasEOIDocuments" class="d-flex flex-wrap gap-2">
<!-- Generate EOI Button - Only show if no documents uploaded and no generated EOI -->
<div v-if="!hasGeneratedEOI && !hasEOIDocuments" class="d-flex flex-wrap gap-2">
<v-btn
@click="generateEOI"
:loading="isGenerating"
@@ -52,9 +52,30 @@
Upload EOI Document
</v-btn>
</div>
<!-- Message when uploaded documents exist -->
<div v-else-if="hasEOIDocuments && !hasGeneratedEOI" class="mb-4">
<v-alert
type="info"
variant="tonal"
class="mb-3"
>
Uploaded EOI documents found. Remove uploaded documents to generate a new EOI.
</v-alert>
<v-btn
@click="showUploadDialog = true"
variant="tonal"
color="success"
prepend-icon="mdi-upload"
:disabled="isUploading"
:size="mobile ? 'default' : 'large'"
>
Upload Additional EOI
</v-btn>
</div>
<!-- Upload EOI Button - Only show if EOI exists or has documents -->
<div v-else-if="hasEOI || hasEOIDocuments" class="mb-4">
<!-- Upload EOI Button - Show if generated EOI exists -->
<div v-else-if="hasGeneratedEOI" class="mb-4">
<v-btn
@click="showUploadDialog = true"
variant="tonal"
@@ -68,7 +89,7 @@
</div>
<!-- EOI Status Badge -->
<div v-if="hasEOI || hasEOIDocuments" class="mb-4 d-flex align-center flex-wrap">
<div v-if="hasGeneratedEOI || hasEOIDocuments" class="mb-4 d-flex align-center flex-wrap">
<v-chip
:color="getStatusColor(interest['EOI Status'])"
variant="tonal"
@@ -83,65 +104,118 @@
</span>
</div>
<!-- Signature Links - Only show if EOI is generated but not uploaded/signed -->
<v-list v-if="hasEOI && !isEOISigned" :density="mobile ? 'compact' : 'comfortable'">
<v-list-item class="mb-2">
<template v-slot:prepend>
<v-avatar color="primary" :size="mobile ? 32 : 40">
<v-icon :size="mobile ? 'small' : 'default'">mdi-account</v-icon>
</v-avatar>
</template>
<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>
<template v-slot:append>
<v-btn
icon="mdi-content-copy"
variant="text"
:size="mobile ? 'small' : 'default'"
@click="copyLink(interest['Signature Link Client'])"
></v-btn>
</template>
</v-list-item>
<!-- Signature Status Section - Only show if EOI is generated but not signed -->
<div v-if="hasGeneratedEOI && !isEOISigned" class="mb-4">
<div class="text-subtitle-2 mb-2 d-flex align-center">
Signature Status
<v-btn
icon="mdi-refresh"
variant="text"
size="x-small"
class="ml-2"
@click="checkSignatureStatus"
:loading="isCheckingSignatures"
></v-btn>
</div>
<v-list :density="mobile ? 'compact' : 'comfortable'">
<v-list-item class="mb-2">
<template v-slot:prepend>
<v-avatar :color="getSignatureStatusColor('client')" :size="mobile ? 32 : 40">
<v-icon :size="mobile ? 'small' : 'default'">
{{ getSignatureStatusIcon('client') }}
</v-icon>
</v-avatar>
</template>
<v-list-item-title :class="mobile ? 'text-body-2' : ''">
Client Signature
<v-chip
v-if="signatureStatus"
:color="getSignatureStatusColor('client')"
size="x-small"
variant="tonal"
class="ml-2"
>
{{ getSignatureStatusText('client') }}
</v-chip>
</v-list-item-title>
<v-list-item-subtitle :class="mobile ? 'text-caption' : ''">{{ interest['Full Name'] }}</v-list-item-subtitle>
<template v-slot:append>
<v-btn
icon="mdi-content-copy"
variant="text"
:size="mobile ? 'small' : 'default'"
@click="copyLink(interest['Signature Link Client'])"
></v-btn>
</template>
</v-list-item>
<v-list-item class="mb-2">
<template v-slot:prepend>
<v-avatar color="success" :size="mobile ? 32 : 40">
<v-icon :size="mobile ? 'small' : 'default'">mdi-account-check</v-icon>
</v-avatar>
</template>
<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>
<template v-slot:append>
<v-btn
icon="mdi-content-copy"
variant="text"
:size="mobile ? 'small' : 'default'"
@click="copyLink(interest['Signature Link CC'])"
></v-btn>
</template>
</v-list-item>
<v-list-item class="mb-2">
<template v-slot:prepend>
<v-avatar :color="getSignatureStatusColor('cc')" :size="mobile ? 32 : 40">
<v-icon :size="mobile ? 'small' : 'default'">
{{ getSignatureStatusIcon('cc') }}
</v-icon>
</v-avatar>
</template>
<v-list-item-title :class="mobile ? 'text-body-2' : ''">
CC Signature
<v-chip
v-if="signatureStatus"
:color="getSignatureStatusColor('cc')"
size="x-small"
variant="tonal"
class="ml-2"
>
{{ getSignatureStatusText('cc') }}
</v-chip>
</v-list-item-title>
<v-list-item-subtitle :class="mobile ? 'text-caption' : ''">Oscar Faragher</v-list-item-subtitle>
<template v-slot:append>
<v-btn
icon="mdi-content-copy"
variant="text"
:size="mobile ? 'small' : 'default'"
@click="copyLink(interest['Signature Link CC'])"
></v-btn>
</template>
</v-list-item>
<v-list-item class="mb-2">
<template v-slot:prepend>
<v-avatar color="secondary" :size="mobile ? 32 : 40">
<v-icon :size="mobile ? 'small' : 'default'">mdi-account-tie</v-icon>
</v-avatar>
</template>
<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>
<template v-slot:append>
<v-btn
icon="mdi-content-copy"
variant="text"
:size="mobile ? 'small' : 'default'"
@click="copyLink(interest['Signature Link Developer'])"
></v-btn>
</template>
</v-list-item>
</v-list>
<v-list-item class="mb-2">
<template v-slot:prepend>
<v-avatar :color="getSignatureStatusColor('developer')" :size="mobile ? 32 : 40">
<v-icon :size="mobile ? 'small' : 'default'">
{{ getSignatureStatusIcon('developer') }}
</v-icon>
</v-avatar>
</template>
<v-list-item-title :class="mobile ? 'text-body-2' : ''">
Developer Signature
<v-chip
v-if="signatureStatus"
:color="getSignatureStatusColor('developer')"
size="x-small"
variant="tonal"
class="ml-2"
>
{{ getSignatureStatusText('developer') }}
</v-chip>
</v-list-item-title>
<v-list-item-subtitle :class="mobile ? 'text-caption' : ''">David Mizrahi</v-list-item-subtitle>
<template v-slot:append>
<v-btn
icon="mdi-content-copy"
variant="text"
:size="mobile ? 'small' : 'default'"
@click="copyLink(interest['Signature Link Developer'])"
></v-btn>
</template>
</v-list-item>
</v-list>
</div>
<!-- Regenerate Button - Only show if EOI is generated but not uploaded/signed -->
<div v-if="hasEOI && !isEOISigned" class="mt-4">
<!-- Action Buttons Section -->
<div v-if="hasGeneratedEOI && !isEOISigned" class="mt-4 d-flex flex-wrap gap-2">
<v-btn
@click="generateEOI"
:loading="isGenerating"
@@ -151,6 +225,17 @@
>
Regenerate EOI
</v-btn>
<v-btn
@click="showDeleteGeneratedConfirmDialog = true"
color="error"
variant="outlined"
size="small"
prepend-icon="mdi-delete"
:disabled="signatureStatus?.allSigned"
>
Delete Generated EOI
</v-btn>
</div>
<!-- Delete EOI Button - Only show if EOI is uploaded/signed -->
@@ -162,7 +247,7 @@
size="small"
prepend-icon="mdi-delete"
>
Delete EOI
Delete Uploaded EOI
</v-btn>
</div>
@@ -231,7 +316,7 @@
</v-card>
</v-dialog>
<!-- Delete Confirmation Dialog -->
<!-- Delete Confirmation Dialog for Uploaded Documents -->
<v-dialog
v-model="showDeleteConfirmDialog"
:max-width="mobile ? '100%' : '400'"
@@ -244,7 +329,7 @@
</v-card-title>
<v-card-text>
Are you sure you want to delete the EOI document? This will:
Are you sure you want to delete the uploaded EOI document? This will:
<ul class="mt-2">
<li>Remove the uploaded EOI document</li>
<li>Reset the Sales Process Level to "Specific Qualified Interest"</li>
@@ -265,7 +350,7 @@
<v-btn
color="error"
variant="flat"
@click="deleteEOI"
@click="deleteUploadedEOI"
:loading="isDeleting"
>
Delete EOI
@@ -273,6 +358,27 @@
</v-card-actions>
</v-card>
</v-dialog>
<!-- Slider Confirmation Dialog for Generated Documents -->
<SliderConfirmation
v-model="showDeleteGeneratedConfirmDialog"
title="Delete Generated EOI"
message="You are about to delete the generated EOI document. This will permanently remove the document from Documenso and reset all signature links."
icon="mdi-delete"
iconColor="error"
confirmButtonText="Delete EOI"
sliderText="Slide to delete generated EOI"
warningText="This action cannot be undone and will require regenerating the document."
:confirmationList="[
'Delete document from Documenso',
'Remove all signature links',
'Reset EOI status to \'Awaiting Further Details\'',
'Reset Sales Process Level to \'Specific Qualified Interest\''
]"
:loading="isDeletingGenerated"
@confirm="deleteGeneratedEOI"
@cancel="showDeleteGeneratedConfirmDialog = false"
/>
</div>
</template>
@@ -296,12 +402,17 @@ const showUploadDialog = ref(false);
const isUploading = ref(false);
const selectedFile = ref<File | null>(null);
const showDeleteConfirmDialog = ref(false);
const showDeleteGeneratedConfirmDialog = ref(false);
const isDeleting = ref(false);
const isDeletingGenerated = ref(false);
const signatureStatus = ref<any>(null);
const isCheckingSignatures = ref(false);
const hasEOI = computed(() => {
const hasGeneratedEOI = computed(() => {
return !!(props.interest['Signature Link Client'] ||
props.interest['Signature Link CC'] ||
props.interest['Signature Link Developer']);
props.interest['Signature Link Developer'] ||
props.interest['documensoID']);
});
const eoiDocuments = computed(() => {
@@ -342,6 +453,11 @@ const generateEOI = async (retryCount = 0) => {
emit('eoi-generated', { signingLinks: response.signingLinks });
emit('update'); // Trigger parent to refresh data
// Check signature status after generation
if (response.documentId !== 'existing') {
setTimeout(() => checkSignatureStatus(), 1000);
}
} else {
throw new Error('EOI generation failed');
}
@@ -362,6 +478,70 @@ const generateEOI = async (retryCount = 0) => {
}
};
const checkSignatureStatus = async () => {
if (!props.interest['documensoID']) return;
isCheckingSignatures.value = true;
try {
const response = await $fetch<any>('/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 {
isCheckingSignatures.value = false;
}
};
const getSignatureStatusColor = (role: string) => {
if (!signatureStatus.value) return 'grey';
const isSigned = signatureStatus.value.signedRecipients?.some((r: any) => {
if (role === 'client') return r.email === props.interest['Email Address'];
if (role === 'cc') return r.email === 'sales@portnimara.com';
if (role === 'developer') return r.email === 'dm@portnimara.com';
return false;
});
return isSigned ? 'success' : 'warning';
};
const getSignatureStatusIcon = (role: string) => {
if (!signatureStatus.value) return 'mdi-clock-outline';
const isSigned = signatureStatus.value.signedRecipients?.some((r: any) => {
if (role === 'client') return r.email === props.interest['Email Address'];
if (role === 'cc') return r.email === 'sales@portnimara.com';
if (role === 'developer') return r.email === 'dm@portnimara.com';
return false;
});
return isSigned ? 'mdi-check' : 'mdi-clock-outline';
};
const getSignatureStatusText = (role: string) => {
if (!signatureStatus.value) return 'Checking...';
const isSigned = signatureStatus.value.signedRecipients?.some((r: any) => {
if (role === 'client') return r.email === props.interest['Email Address'];
if (role === 'cc') return r.email === 'sales@portnimara.com';
if (role === 'developer') return r.email === 'dm@portnimara.com';
return false;
});
return isSigned ? 'Signed' : 'Pending';
};
const copyLink = async (link: string | undefined) => {
if (!link) return;
@@ -473,13 +653,12 @@ const handleUpload = () => {
}
};
const closeUploadDialog = () => {
showUploadDialog.value = false;
selectedFile.value = null;
};
const deleteEOI = async () => {
const deleteUploadedEOI = async () => {
isDeleting.value = true;
try {
@@ -494,15 +673,50 @@ const deleteEOI = async () => {
});
if (response.success) {
toast.success('EOI deleted successfully');
toast.success('Uploaded EOI deleted successfully');
showDeleteConfirmDialog.value = false;
emit('update'); // Refresh parent data
}
} catch (error: any) {
console.error('Failed to delete EOI:', error);
toast.error(error.data?.statusMessage || 'Failed to delete EOI');
console.error('Failed to delete uploaded EOI:', error);
toast.error(error.data?.statusMessage || 'Failed to delete uploaded EOI');
} finally {
isDeleting.value = false;
}
};
const deleteGeneratedEOI = async () => {
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');
showDeleteGeneratedConfirmDialog.value = false;
signatureStatus.value = null; // Reset signature status
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;
}
};
// Auto-check signature status when component mounts if there's a generated EOI
onMounted(() => {
if (hasGeneratedEOI.value && !isEOISigned.value) {
checkSignatureStatus();
}
});
</script>

View File

@@ -0,0 +1,251 @@
<template>
<v-dialog
v-model="showDialog"
:max-width="mobile ? '100%' : '500'"
:fullscreen="mobile"
:transition="mobile ? 'dialog-bottom-transition' : 'dialog-transition'"
persistent
>
<v-card>
<v-card-title class="d-flex align-center">
<v-icon class="mr-2" :color="iconColor">{{ icon }}</v-icon>
{{ title }}
<v-spacer />
<v-btn
icon="mdi-close"
variant="text"
@click="cancel"
:disabled="isSliding"
></v-btn>
</v-card-title>
<v-divider />
<v-card-text :class="mobile ? 'pa-4' : 'pa-6'">
<div :class="mobile ? 'text-body-2 mb-4' : 'text-body-1 mb-6'">
{{ message }}
</div>
<div v-if="confirmationList && confirmationList.length > 0" class="mb-4">
<ul class="text-caption">
<li v-for="item in confirmationList" :key="item">{{ item }}</li>
</ul>
</div>
<div class="text-error mb-4" :class="mobile ? 'text-caption' : 'text-body-2'">
{{ warningText }}
</div>
<!-- Slider Track -->
<div class="slider-container mb-4">
<div class="slider-track" ref="sliderTrack">
<div
class="slider-fill"
:style="{ width: `${sliderProgress}%` }"
></div>
<div
class="slider-handle"
:style="{ left: `${sliderProgress}%` }"
@mousedown="startSliding"
@touchstart="startSliding"
>
<v-icon size="20" color="white">
{{ sliderProgress >= 100 ? 'mdi-check' : 'mdi-chevron-right' }}
</v-icon>
</div>
</div>
<div class="slider-text" :class="mobile ? 'text-caption' : 'text-body-2'">
{{ sliderProgress >= 100 ? 'Ready to delete!' : sliderText }}
</div>
</div>
</v-card-text>
<v-divider />
<v-card-actions class="pa-4">
<v-spacer />
<v-btn
@click="cancel"
variant="text"
:size="mobile ? 'default' : 'large'"
:disabled="isSliding"
>
Cancel
</v-btn>
<v-btn
color="error"
variant="flat"
@click="confirm"
:loading="isConfirming"
:disabled="sliderProgress < 100"
:size="mobile ? 'default' : 'large'"
>
{{ confirmButtonText }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
interface Props {
modelValue: boolean;
title?: string;
message?: string;
icon?: string;
iconColor?: string;
confirmButtonText?: string;
sliderText?: string;
warningText?: string;
confirmationList?: string[];
loading?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
title: 'Confirm Action',
message: 'Are you sure you want to proceed?',
icon: 'mdi-alert',
iconColor: 'warning',
confirmButtonText: 'Confirm',
sliderText: 'Slide to confirm',
warningText: 'This action cannot be undone.',
confirmationList: () => [],
loading: false
});
const emit = defineEmits<{
'update:modelValue': [value: boolean];
'confirm': [];
'cancel': [];
}>();
const { mobile } = useDisplay();
const showDialog = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
});
const isConfirming = computed(() => props.loading);
// Slider state
const sliderProgress = ref(0);
const isSliding = ref(false);
const sliderTrack = ref<HTMLElement>();
const startSliding = (event: MouseEvent | TouchEvent) => {
event.preventDefault();
isSliding.value = true;
const handleMove = (moveEvent: MouseEvent | TouchEvent) => {
if (!sliderTrack.value || !isSliding.value) return;
const rect = sliderTrack.value.getBoundingClientRect();
const clientX = 'touches' in moveEvent ? moveEvent.touches[0].clientX : moveEvent.clientX;
const progress = Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100));
sliderProgress.value = progress;
};
const handleEnd = () => {
isSliding.value = false;
if (sliderProgress.value < 100) {
// Snap back if not fully slid
sliderProgress.value = 0;
}
document.removeEventListener('mousemove', handleMove);
document.removeEventListener('mouseup', handleEnd);
document.removeEventListener('touchmove', handleMove);
document.removeEventListener('touchend', handleEnd);
};
document.addEventListener('mousemove', handleMove);
document.addEventListener('mouseup', handleEnd);
document.addEventListener('touchmove', handleMove);
document.addEventListener('touchend', handleEnd);
};
const confirm = () => {
if (sliderProgress.value >= 100) {
emit('confirm');
}
};
const cancel = () => {
if (!isSliding.value) {
sliderProgress.value = 0;
emit('cancel');
showDialog.value = false;
}
};
// Reset slider when dialog is closed
watch(showDialog, (newValue) => {
if (!newValue) {
sliderProgress.value = 0;
}
});
</script>
<style scoped>
.slider-container {
width: 100%;
}
.slider-track {
position: relative;
width: 100%;
height: 50px;
background-color: #e0e0e0;
border-radius: 25px;
overflow: hidden;
cursor: pointer;
user-select: none;
}
.slider-fill {
position: absolute;
top: 0;
left: 0;
height: 100%;
background: linear-gradient(90deg, #ff5722, #d32f2f);
border-radius: 25px;
transition: width 0.1s ease;
}
.slider-handle {
position: absolute;
top: 4px;
width: 42px;
height: 42px;
background-color: #fff;
border-radius: 50%;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
cursor: grab;
display: flex;
align-items: center;
justify-content: center;
transform: translateX(-50%);
transition: transform 0.1s ease;
z-index: 1;
}
.slider-handle:active {
cursor: grabbing;
transform: translateX(-50%) scale(1.1);
}
.slider-text {
text-align: center;
margin-top: 8px;
color: #666;
}
/* Prevent text selection during sliding */
.slider-container * {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
</style>