This commit is contained in:
Matt 2025-06-10 16:48:40 +02:00
parent 49aa47ab10
commit 839b307edd
8 changed files with 473 additions and 57 deletions

View File

@ -74,9 +74,123 @@
</div>
<!-- Email Thread List -->
<div v-if="emailThreads.length > 0" class="email-threads">
<div class="text-subtitle-1 mb-3">Email History</div>
<v-timeline :side="mobile ? 'end' : 'end'" :density="mobile ? 'compact' : 'comfortable'">
<div v-if="emailThreads.length > 0 || threads.length > 0" class="email-threads">
<div class="text-subtitle-1 mb-3 d-flex align-center">
Email History
<v-spacer />
<v-btn-toggle
v-model="viewMode"
mandatory
density="compact"
variant="outlined"
size="small"
>
<v-btn value="threads" size="small">
<v-icon :start="!mobile">mdi-forum</v-icon>
<span v-if="!mobile" class="ml-1">Threads</span>
</v-btn>
<v-btn value="all" size="small">
<v-icon :start="!mobile">mdi-email-multiple</v-icon>
<span v-if="!mobile" class="ml-1">All</span>
</v-btn>
</v-btn-toggle>
</div>
<!-- Thread View -->
<div v-if="viewMode === 'threads' && threads.length > 0">
<v-expansion-panels v-model="expandedThreads" multiple>
<v-expansion-panel v-for="thread in threads" :key="thread.id">
<v-expansion-panel-title>
<div class="d-flex align-center justify-space-between w-100">
<div>
<div class="text-body-2 font-weight-medium">{{ thread.subject }}</div>
<div class="text-caption text-grey">
{{ thread.emailCount }} {{ thread.emailCount === 1 ? 'email' : 'emails' }}
Last activity {{ formatRelativeTime(thread.latestTimestamp) }}
</div>
</div>
<v-chip size="small" color="primary" variant="tonal">
{{ thread.emailCount }}
</v-chip>
</div>
</v-expansion-panel-title>
<v-expansion-panel-text>
<v-timeline :density="mobile ? 'compact' : 'comfortable'" side="end">
<v-timeline-item
v-for="(email, emailIndex) in thread.emails"
:key="emailIndex"
:dot-color="email.direction === 'sent' ? 'primary' : 'success'"
:icon="email.direction === 'sent' ? 'mdi-email-send' : 'mdi-email-receive'"
:size="mobile ? 'x-small' : 'small'"
>
<v-card
variant="outlined"
:density="mobile ? 'compact' : 'default'"
@click="viewEmail(email)"
class="email-card"
:hover="true"
>
<v-card-subtitle class="d-flex align-center justify-space-between">
<span class="text-body-2">
{{ email.direction === 'sent' ? 'To' : 'From' }}:
{{ email.direction === 'sent' ? email.to : email.from }}
</span>
<span class="text-caption text-grey">
{{ formatDate(email.timestamp) }}
</span>
</v-card-subtitle>
<v-card-text :class="mobile ? 'pa-3' : ''">
<div class="email-content email-preview" :class="mobile ? 'email-content-mobile' : ''" v-html="formatEmailContent(email.content || email.body)"></div>
<!-- Attachments -->
<div v-if="email.attachments && email.attachments.length > 0" class="mt-3">
<v-chip
v-for="(attachment, i) in email.attachments"
:key="i"
size="small"
color="primary"
variant="tonal"
prepend-icon="mdi-paperclip"
class="mr-2"
>
{{ attachment.name || attachment }}
</v-chip>
</div>
<div class="d-flex justify-space-between mt-3">
<v-btn variant="text" size="small" color="primary" @click.stop="viewEmail(email)">
<v-icon start>mdi-email-open</v-icon>
View
</v-btn>
<v-btn variant="text" size="small" color="primary" @click.stop="replyToEmail(email)">
<v-icon start>mdi-reply</v-icon>
Reply
</v-btn>
</div>
</v-card-text>
</v-card>
</v-timeline-item>
</v-timeline>
<!-- Reply to thread button -->
<div class="text-center mt-3">
<v-btn
@click="replyToThread(thread)"
color="primary"
variant="tonal"
prepend-icon="mdi-reply"
>
Reply to Thread
</v-btn>
</div>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</div>
<!-- All Emails View (Original) -->
<v-timeline v-else :side="mobile ? 'end' : 'end'" :density="mobile ? 'compact' : 'comfortable'">
<v-timeline-item
v-for="(email, index) in emailThreads"
:key="index"
@ -90,7 +204,13 @@
</div>
</template>
<v-card variant="outlined" :density="mobile ? 'compact' : 'default'">
<v-card
variant="outlined"
:density="mobile ? 'compact' : 'default'"
@click="viewEmail(email)"
class="email-card"
:hover="true"
>
<v-card-subtitle class="d-flex align-center justify-space-between">
<span class="text-body-2">
{{ email.direction === 'sent' ? 'To' : 'From' }}:
@ -103,7 +223,7 @@
<v-card-text :class="mobile ? 'pa-3' : ''">
<div class="text-body-2 font-weight-medium mb-2">{{ email.subject }}</div>
<div class="email-content" :class="mobile ? 'email-content-mobile' : ''" v-html="formatEmailContent(email.content)"></div>
<div class="email-content email-preview" :class="mobile ? 'email-content-mobile' : ''" v-html="formatEmailContent(email.content || email.body)"></div>
<!-- Attachments -->
<div v-if="email.attachments && email.attachments.length > 0" class="mt-3">
@ -116,9 +236,20 @@
prepend-icon="mdi-paperclip"
class="mr-2"
>
{{ attachment.name }}
{{ attachment.name || attachment }}
</v-chip>
</div>
<div class="d-flex justify-space-between mt-3">
<v-btn variant="text" size="small" color="primary">
<v-icon start>mdi-email-open</v-icon>
View Full Email
</v-btn>
<v-btn variant="text" size="small" color="primary" @click.stop="replyToEmail(email)">
<v-icon start>mdi-reply</v-icon>
Reply
</v-btn>
</div>
</v-card-text>
</v-card>
</v-timeline-item>
@ -392,12 +523,19 @@
</v-card-actions>
</v-card>
</v-dialog>
<!-- Email Details Dialog -->
<EmailDetailsDialog
v-model="showEmailDetails"
:email="selectedEmail"
/>
</div>
</template>
<script setup lang="ts">
import type { Interest } from '~/utils/types';
import FileBrowser from '~/pages/dashboard/file-browser.vue';
import EmailDetailsDialog from '~/components/EmailDetailsDialog.vue';
const props = defineProps<{
interest: Interest;
@ -423,6 +561,12 @@ const includeSignature = ref(true);
const signatureConfig = ref<any>({});
const showFileBrowser = ref(false);
const tempSelectedFiles = ref<any[]>([]);
const showEmailDetails = ref(false);
const selectedEmail = ref<any>(null);
const threads = ref<any[]>([]);
const viewMode = ref('threads');
const expandedThreads = ref<number[]>([0]); // Expand first thread by default
const replyingTo = ref<any>(null);
const emailDraft = ref<{
subject: string;
@ -530,7 +674,7 @@ const loadEmailThread = async () => {
// Check if we have threads from the API response
if (response.threads) {
console.log('[ClientEmailSection] Threads available:', response.threads.length);
// For now, still use emails until we implement thread UI
threads.value = response.threads;
}
}
} catch (error) {
@ -747,6 +891,7 @@ const closeComposer = () => {
};
attachmentMode.value = 'upload';
selectedBrowserFiles.value = [];
replyingTo.value = null;
};
const removeBrowserFile = (index: number) => {
@ -840,15 +985,86 @@ const cancelFileBrowser = () => {
showFileBrowser.value = false;
tempSelectedFiles.value = [];
};
const viewEmail = (email: any) => {
selectedEmail.value = email;
showEmailDetails.value = true;
};
const replyToEmail = (email: any) => {
replyingTo.value = email;
// Pre-fill the subject with Re: prefix if not already there
const subject = email.subject || '';
emailDraft.value.subject = subject.startsWith('Re:') ? subject : `Re: ${subject}`;
// Pre-fill with a reply header
const originalDate = formatDate(email.timestamp);
const originalFrom = email.direction === 'sent' ? 'you' : email.from;
emailDraft.value.content = `\n\n\n--- Original Message ---\nFrom: ${originalFrom}\nDate: ${originalDate}\n\n${email.content || email.body || ''}`;
showComposer.value = true;
};
const replyToThread = (thread: any) => {
// Reply to the last email in the thread
const lastEmail = thread.emails[thread.emails.length - 1];
replyToEmail(lastEmail);
};
const formatRelativeTime = (timestamp: string) => {
const date = new Date(timestamp);
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return 'just now';
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
if (days < 7) return `${days}d ago`;
return date.toLocaleDateString('en-GB', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
};
</script>
<style scoped>
.email-card {
cursor: pointer;
transition: all 0.2s;
}
.email-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
.email-content {
max-height: 200px;
overflow-y: auto;
line-height: 1.6;
}
.email-preview {
max-height: 100px;
overflow: hidden;
position: relative;
}
.email-preview::after {
content: '';
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 30px;
background: linear-gradient(to bottom, transparent, white);
}
.email-content-mobile {
max-height: 150px;
font-size: 0.875rem;

View File

@ -85,7 +85,7 @@
:preferred-countries="['US', 'FR', 'ES', 'PT', 'GB']"
/>
</v-col>
<v-col cols="12" md="4">
<v-col cols="12" md="6">
<v-text-field
v-model="newInterest.Address"
label="Address"
@ -94,16 +94,7 @@
prepend-inner-icon="mdi-map-marker"
></v-text-field>
</v-col>
<v-col cols="12" md="4">
<v-text-field
v-model="newInterest['Place of Residence']"
label="Place of Residence"
variant="outlined"
density="comfortable"
prepend-inner-icon="mdi-home"
></v-text-field>
</v-col>
<v-col cols="12" md="4">
<v-col cols="12" md="6">
<v-select
v-model="newInterest['Contact Method Preferred']"
label="Contact Method Preferred"
@ -353,7 +344,6 @@ const getInitialInterest = () => ({
"Phone Number": "",
"Contact Method Preferred": "Email",
Address: "",
"Place of Residence": "",
"Yacht Name": "",
"Berth Size Desired": "",
Length: "",

View File

@ -35,11 +35,11 @@
:loading="isGenerating"
color="primary"
variant="flat"
:prepend-icon="!mobile ? 'mdi-file-document-plus' : undefined"
:icon="mobile ? 'mdi-file-document-plus' : undefined"
prepend-icon="mdi-file-document-plus"
:size="mobile ? 'default' : 'default'"
:block="mobile"
>
<span v-if="!mobile">Generate EOI</span>
Generate EOI
</v-btn>
</div>

View File

@ -0,0 +1,149 @@
<template>
<v-dialog
v-model="isOpen"
:max-width="mobile ? '100%' : '800'"
:fullscreen="mobile"
:transition="mobile ? 'dialog-bottom-transition' : 'dialog-transition'"
>
<v-card v-if="email">
<v-card-title class="d-flex align-center">
<v-icon class="mr-2" :color="email.direction === 'sent' ? 'primary' : 'success'">
{{ email.direction === 'sent' ? 'mdi-email-send' : 'mdi-email-receive' }}
</v-icon>
Email Details
<v-spacer />
<v-btn icon="mdi-close" variant="text" @click="close"></v-btn>
</v-card-title>
<v-divider />
<v-card-text>
<v-list density="comfortable">
<v-list-item>
<template v-slot:prepend>
<v-icon>mdi-account</v-icon>
</template>
<v-list-item-title>{{ email.direction === 'sent' ? 'To' : 'From' }}</v-list-item-title>
<v-list-item-subtitle>{{ email.direction === 'sent' ? email.to : email.from }}</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<template v-slot:prepend>
<v-icon>mdi-text-box</v-icon>
</template>
<v-list-item-title>Subject</v-list-item-title>
<v-list-item-subtitle>{{ email.subject }}</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<template v-slot:prepend>
<v-icon>mdi-calendar-clock</v-icon>
</template>
<v-list-item-title>Date & Time</v-list-item-title>
<v-list-item-subtitle>{{ formatDate(email.timestamp) }}</v-list-item-subtitle>
</v-list-item>
</v-list>
<v-divider class="my-4" />
<div class="email-body">
<div class="text-subtitle-2 mb-2">Message</div>
<div class="email-content" v-html="formatEmailContent(email.content || email.body)"></div>
</div>
<!-- Attachments -->
<div v-if="email.attachments && email.attachments.length > 0" class="mt-4">
<div class="text-subtitle-2 mb-2">Attachments ({{ email.attachments.length }})</div>
<v-chip
v-for="(attachment, i) in email.attachments"
:key="i"
size="small"
color="primary"
variant="tonal"
prepend-icon="mdi-paperclip"
class="mr-2 mb-2"
>
{{ attachment.name || attachment }}
</v-chip>
</div>
</v-card-text>
<v-divider />
<v-card-actions>
<v-spacer />
<v-btn @click="close" variant="text">Close</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
interface Props {
modelValue: boolean;
email: any;
}
const props = defineProps<Props>();
const emit = defineEmits(['update:modelValue']);
const { mobile } = useDisplay();
const isOpen = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
});
const close = () => {
isOpen.value = false;
};
const formatDate = (dateString: string) => {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleString('en-GB', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false
});
};
const formatEmailContent = (content: string) => {
if (!content) return '';
// If it's already HTML, return as is
if (content.includes('<p>') || content.includes('<br>')) {
return content;
}
// Convert plain text to HTML
return content
.split('\n')
.map(line => line.trim() ? `<p>${line}</p>` : '<br>')
.join('');
};
</script>
<style scoped>
.email-content {
font-family: inherit;
line-height: 1.6;
word-wrap: break-word;
}
.email-content :deep(p) {
margin: 0 0 0.5em 0;
}
.email-content :deep(br) {
display: block;
content: "";
margin: 0.25em 0;
}
</style>

View File

@ -1222,24 +1222,22 @@ const getSalesLevelColor = (level: string) => {
// Confirm delete
const confirmDelete = () => {
if (!interest.value) return;
if (!interest.value || isDeleting.value) return;
if (confirm(`Are you sure you want to delete the interest for ${interest.value['Full Name']}? This action cannot be undone.`)) {
if (debouncedDeleteInterest) {
debouncedDeleteInterest();
} else {
deleteInterest();
}
deleteInterest();
}
};
// Delete interest
const deleteInterest = async () => {
if (!interest.value) return;
if (!interest.value || isDeleting.value) return;
console.log('[InterestDetailsModal] Starting delete for interest:', interest.value.Id);
isDeleting.value = true;
try {
await $fetch("/api/delete-interest", {
const response = await $fetch("/api/delete-interest", {
method: "POST",
headers: {
"x-tag": user.value?.email ? "094ut234" : "pjnvü1230",
@ -1249,12 +1247,15 @@ const deleteInterest = async () => {
},
});
console.log('[InterestDetailsModal] Delete response:', response);
toast.success("Interest deleted successfully!");
closeModal();
emit("save", interest.value); // Trigger refresh
} catch (error) {
console.error("Failed to delete interest:", error);
toast.error("Failed to delete interest. Please try again.");
} catch (error: any) {
console.error("[InterestDetailsModal] Failed to delete interest:", error);
console.error("[InterestDetailsModal] Error details:", error.data || error.message);
const errorMessage = error.data?.statusMessage || error.message || "Failed to delete interest. Please try again.";
toast.error(errorMessage);
} finally {
isDeleting.value = false;
}

View File

@ -585,37 +585,51 @@ const getRelativeTime = (dateString: string) => {
/* Mobile-specific styles */
@media (max-width: 768px) {
.table-container {
margin: 0 -12px;
padding: 0 12px;
position: relative;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
margin: 0 -16px;
}
/* For mobile, only show essential columns */
/* Add padding to the wrapper instead */
.modern-table :deep(.v-table__wrapper) {
min-width: auto;
padding: 0 16px;
min-width: 600px; /* Minimum width to ensure scrolling */
}
.modern-table :deep(th) {
padding: 8px !important;
font-size: 0.75rem;
white-space: nowrap;
}
.modern-table :deep(td) {
padding: 12px 8px !important;
}
/* Hide columns on mobile that aren't in mobile headers */
.modern-table :deep(th:nth-child(n+4)),
.modern-table :deep(td:nth-child(n+4)) {
display: none;
/* Show all columns but with smaller widths on mobile */
.modern-table :deep(th:nth-child(1)),
.modern-table :deep(td:nth-child(1)) {
min-width: 180px !important;
}
.modern-table :deep(th:nth-child(2)),
.modern-table :deep(td:nth-child(2)) {
min-width: 120px !important;
}
.modern-table :deep(th:nth-child(3)),
.modern-table :deep(td:nth-child(3)) {
min-width: 100px !important;
}
/* Contact cell optimization */
.contact-cell {
max-width: 200px;
max-width: 180px;
}
.contact-cell .text-truncate {
max-width: 150px;
max-width: 140px;
}
/* Adjust table row height on mobile */
@ -628,6 +642,18 @@ const getRelativeTime = (dateString: string) => {
height: 20px !important;
font-size: 0.625rem !important;
}
/* Add visual scroll indicators */
.table-container::after {
content: '';
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 30px;
background: linear-gradient(to right, transparent, rgba(255,255,255,0.8));
pointer-events: none;
}
}
/* Ensure proper text truncation */

View File

@ -107,7 +107,16 @@ export default defineEventHandler(async (event) => {
// Update interest with EOI document information
console.log('[EOI Upload] Updating interest with EOI document info');
await updateInterestEOIDocument(interestId, documentData);
console.log('[EOI Upload] Document data:', JSON.stringify(documentData, null, 2));
try {
await updateInterestEOIDocument(interestId, documentData);
console.log('[EOI Upload] Successfully updated EOI document in database');
} catch (dbError: any) {
console.error('[EOI Upload] Failed to update database with EOI document:', dbError);
console.error('[EOI Upload] Database error details:', dbError.data || dbError.message);
throw new Error(`Failed to update database: ${dbError.message}`);
}
// Update the status fields for uploaded (signed) EOI
const updateData: any = {
@ -116,18 +125,26 @@ export default defineEventHandler(async (event) => {
'Sales Process Level': 'Signed LOI and NDA'
};
console.log('[EOI Upload] Updating interest status fields for signed EOI');
console.log('[EOI Upload] Status update data:', JSON.stringify(updateData, null, 2));
// Update the interest
await $fetch('/api/update-interest', {
method: 'POST',
headers: {
'x-tag': xTagHeader,
},
body: {
id: interestId,
data: updateData
}
});
try {
// Update the interest
await $fetch('/api/update-interest', {
method: 'POST',
headers: {
'x-tag': xTagHeader,
},
body: {
id: interestId,
data: updateData
}
});
console.log('[EOI Upload] Successfully updated interest status');
} catch (statusError: any) {
console.error('[EOI Upload] Failed to update interest status:', statusError);
console.error('[EOI Upload] Status error details:', statusError.data || statusError.message);
// Don't throw here - the file was uploaded successfully
}
console.log('[EOI Upload] Upload completed successfully');
return {

View File

@ -4,12 +4,20 @@ import { promises as fs } from 'fs';
import mime from 'mime-types';
export default defineEventHandler(async (event) => {
const xTagHeader = getRequestHeader(event, "x-tag");
if (!xTagHeader || (xTagHeader !== "094ut234" && xTagHeader !== "pjnvü1230")) {
throw createError({ statusCode: 401, statusMessage: "unauthenticated" });
}
try {
// Get the current path and bucket from query params
const query = getQuery(event);
const currentPath = (query.path as string) || '';
const bucket = (query.bucket as string) || 'client-portal'; // Default bucket
console.log('[Upload] Request received for bucket:', bucket, 'path:', currentPath);
// Parse multipart form data
const form = formidable({
maxFileSize: 50 * 1024 * 1024, // 50MB limit
@ -50,6 +58,15 @@ export default defineEventHandler(async (event) => {
} else {
// For other buckets, use the MinIO client directly
const client = getMinioClient();
// Ensure bucket exists
try {
await client.bucketExists(bucket);
} catch (err) {
console.log(`[Upload] Bucket ${bucket} doesn't exist, creating it...`);
await client.makeBucket(bucket, 'us-east-1');
}
await client.putObject(bucket, fullPath, fileBuffer, fileBuffer.length, {
'Content-Type': contentType,
});