1319 lines
43 KiB
Vue
1319 lines
43 KiB
Vue
<template>
|
|
<v-dialog
|
|
v-model="isOpen"
|
|
fullscreen
|
|
hide-overlay
|
|
transition="dialog-bottom-transition"
|
|
:scrollable="false"
|
|
>
|
|
<v-card class="d-flex flex-column" style="height: 100vh;">
|
|
<v-toolbar dark color="primary">
|
|
<v-btn icon dark @click="closeModal">
|
|
<v-icon>mdi-close</v-icon>
|
|
</v-btn>
|
|
<v-toolbar-title>
|
|
<v-icon class="mr-2">mdi-account-details</v-icon>
|
|
{{ interest?.['Full Name'] || 'Interest Details' }}
|
|
</v-toolbar-title>
|
|
<v-spacer></v-spacer>
|
|
<v-toolbar-items v-if="!mobile">
|
|
<v-btn
|
|
@click="requestMoreInfoToSales"
|
|
variant="text"
|
|
:loading="isRequestingMoreInfo"
|
|
:disabled="
|
|
isRequestingMoreInfo ||
|
|
isRequestingMoreInformation ||
|
|
isSendingEOI ||
|
|
isDeleting
|
|
"
|
|
>
|
|
<v-icon start>mdi-information-outline</v-icon>
|
|
Info to Sales
|
|
</v-btn>
|
|
<v-btn
|
|
@click="requestMoreInformation"
|
|
variant="text"
|
|
:loading="isRequestingMoreInformation"
|
|
:disabled="
|
|
isRequestingMoreInfo ||
|
|
isRequestingMoreInformation ||
|
|
isSendingEOI ||
|
|
isDeleting
|
|
"
|
|
>
|
|
<v-icon start>mdi-email-outline</v-icon>
|
|
Request Info
|
|
</v-btn>
|
|
<!-- EOI to Sales button hidden -->
|
|
<v-btn
|
|
@click="confirmDelete"
|
|
variant="flat"
|
|
color="error"
|
|
:loading="isDeleting"
|
|
:disabled="
|
|
isRequestingMoreInfo ||
|
|
isRequestingMoreInformation ||
|
|
isSendingEOI ||
|
|
isSaving ||
|
|
isDeleting
|
|
"
|
|
class="ml-4"
|
|
>
|
|
<v-icon start color="white">mdi-delete</v-icon>
|
|
<span class="text-white">Delete</span>
|
|
</v-btn>
|
|
<v-btn
|
|
variant="flat"
|
|
color="success"
|
|
size="large"
|
|
@click="() => debouncedSaveInterest ? debouncedSaveInterest() : saveInterest()"
|
|
:loading="isSaving"
|
|
:disabled="isSaving || isDeleting"
|
|
class="ml-2"
|
|
>
|
|
<v-icon start>mdi-content-save</v-icon>
|
|
Save Changes
|
|
</v-btn>
|
|
</v-toolbar-items>
|
|
</v-toolbar>
|
|
|
|
<v-card-text v-if="interest" class="flex-grow-1 overflow-y-auto pa-0">
|
|
<div class="pa-4">
|
|
<v-stepper
|
|
v-show="!mobile"
|
|
v-model="currentStep"
|
|
class="mb-6"
|
|
variant="flat"
|
|
alt-labels
|
|
elevation="0"
|
|
bg-color="transparent"
|
|
>
|
|
<v-stepper-header>
|
|
<template
|
|
v-for="(level, index) in InterestSalesProcessLevelFlow"
|
|
:key="index"
|
|
>
|
|
<v-stepper-item
|
|
:step="index"
|
|
:complete="currentStep + 1 > index"
|
|
:title="level"
|
|
:color="currentStep === index ? getSalesLevelColor(level) : (currentStep + 1 > index ? getSalesLevelColor(level) : 'grey')"
|
|
:icon="currentStep + 1 > index ? 'mdi-check' : undefined"
|
|
/>
|
|
<v-divider
|
|
v-if="index < InterestSalesProcessLevelFlow.length - 1"
|
|
></v-divider>
|
|
</template>
|
|
</v-stepper-header>
|
|
</v-stepper>
|
|
|
|
<!-- Non-editable fields as informational badges -->
|
|
<v-row class="mb-4">
|
|
<v-col cols="12">
|
|
<div class="d-flex flex-wrap ga-2">
|
|
<v-chip
|
|
v-if="interest['Created At']"
|
|
color="info"
|
|
variant="tonal"
|
|
prepend-icon="mdi-calendar-clock"
|
|
>
|
|
Created: {{ formatDate(interest["Created At"]) }}
|
|
</v-chip>
|
|
<v-chip
|
|
v-if="interest['Request Form Sent']"
|
|
color="success"
|
|
variant="tonal"
|
|
prepend-icon="mdi-email-check"
|
|
>
|
|
Request Form Sent:
|
|
{{ formatDate(interest["Request Form Sent"]) }}
|
|
</v-chip>
|
|
<v-chip
|
|
v-if="interest['EOI Time Sent']"
|
|
color="warning"
|
|
variant="tonal"
|
|
>
|
|
EOI Sent: {{ formatDate(interest["EOI Time Sent"]) }}
|
|
</v-chip>
|
|
<v-chip
|
|
v-if="interest['Time LOI Sent']"
|
|
color="secondary"
|
|
variant="tonal"
|
|
prepend-icon="mdi-file-document-check"
|
|
>
|
|
LOI Sent: {{ formatDate(interest["Time LOI Sent"]) }}
|
|
</v-chip>
|
|
</div>
|
|
</v-col>
|
|
</v-row>
|
|
|
|
<!-- Mobile Action Buttons -->
|
|
<v-card v-if="mobile" 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-gesture-tap</v-icon>
|
|
Actions
|
|
</v-card-title>
|
|
<v-card-text class="pt-2">
|
|
<v-row dense>
|
|
<v-col cols="6">
|
|
<v-btn
|
|
@click="requestMoreInfoToSales"
|
|
variant="outlined"
|
|
color="primary"
|
|
block
|
|
:loading="isRequestingMoreInfo"
|
|
:disabled="
|
|
isRequestingMoreInfo ||
|
|
isRequestingMoreInformation ||
|
|
isSendingEOI
|
|
"
|
|
class="mb-2 d-flex flex-column"
|
|
size="large"
|
|
>
|
|
<v-icon size="large" class="mb-1">mdi-information-outline</v-icon>
|
|
<span class="text-caption">Info to Sales</span>
|
|
</v-btn>
|
|
</v-col>
|
|
<v-col cols="6">
|
|
<v-btn
|
|
@click="requestMoreInformation"
|
|
variant="outlined"
|
|
color="primary"
|
|
block
|
|
:loading="isRequestingMoreInformation"
|
|
:disabled="
|
|
isRequestingMoreInfo ||
|
|
isRequestingMoreInformation ||
|
|
isSendingEOI
|
|
"
|
|
class="mb-2 d-flex flex-column"
|
|
size="large"
|
|
>
|
|
<v-icon size="large" class="mb-1">mdi-email-outline</v-icon>
|
|
<span class="text-caption">Request Info</span>
|
|
</v-btn>
|
|
</v-col>
|
|
<v-col cols="12">
|
|
<v-btn
|
|
@click="() => debouncedSaveInterest ? debouncedSaveInterest() : saveInterest()"
|
|
variant="flat"
|
|
color="success"
|
|
block
|
|
:loading="isSaving"
|
|
:disabled="isSaving"
|
|
class="mb-2 d-flex flex-column"
|
|
size="large"
|
|
>
|
|
<v-icon size="large" class="mb-1">mdi-content-save</v-icon>
|
|
<span class="text-caption">Save Changes</span>
|
|
</v-btn>
|
|
</v-col>
|
|
</v-row>
|
|
</v-card-text>
|
|
</v-card>
|
|
|
|
<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">
|
|
<v-icon class="mr-2" color="primary">mdi-account-circle</v-icon>
|
|
Contact Information
|
|
</v-card-title>
|
|
<v-card-text class="pt-2">
|
|
<v-row dense>
|
|
<v-col cols="12" md="4">
|
|
<v-text-field
|
|
v-model="interest['Full Name']"
|
|
label="Full Name"
|
|
variant="outlined"
|
|
density="comfortable"
|
|
prepend-inner-icon="mdi-account"
|
|
></v-text-field>
|
|
</v-col>
|
|
<v-col cols="12" md="4">
|
|
<v-text-field
|
|
v-model="interest['Email Address']"
|
|
label="Email Address"
|
|
variant="outlined"
|
|
density="comfortable"
|
|
prepend-inner-icon="mdi-email"
|
|
></v-text-field>
|
|
</v-col>
|
|
<v-col cols="12" md="4">
|
|
<PhoneInput
|
|
v-model="interest['Phone Number']"
|
|
label="Phone Number"
|
|
variant="outlined"
|
|
density="comfortable"
|
|
default-country="US"
|
|
:preferred-countries="['US', 'FR', 'ES', 'PT', 'GB']"
|
|
/>
|
|
</v-col>
|
|
<v-col cols="12" md="4">
|
|
<v-text-field
|
|
v-model="interest.Address"
|
|
label="Address"
|
|
variant="outlined"
|
|
density="comfortable"
|
|
prepend-inner-icon="mdi-map-marker"
|
|
></v-text-field>
|
|
</v-col>
|
|
<v-col cols="12" md="4">
|
|
<v-select
|
|
v-model="interest['Contact Method Preferred']"
|
|
label="Contact Method Preferred"
|
|
variant="outlined"
|
|
density="comfortable"
|
|
:items="ContactMethodPreferredFlow"
|
|
prepend-inner-icon="mdi-message-text"
|
|
></v-select>
|
|
</v-col>
|
|
</v-row>
|
|
</v-card-text>
|
|
</v-card>
|
|
|
|
<!-- Yacht Information Section -->
|
|
<v-card 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-sail-boat</v-icon>
|
|
Yacht Information
|
|
</v-card-title>
|
|
<v-card-text class="pt-2">
|
|
<v-row dense>
|
|
<v-col cols="12" md="3">
|
|
<v-text-field
|
|
v-model="interest['Yacht Name']"
|
|
label="Yacht Name"
|
|
variant="outlined"
|
|
density="comfortable"
|
|
prepend-inner-icon="mdi-ferry"
|
|
></v-text-field>
|
|
</v-col>
|
|
<v-col cols="12" md="3">
|
|
<v-text-field
|
|
v-model="interest.Length"
|
|
label="Length"
|
|
variant="outlined"
|
|
density="comfortable"
|
|
prepend-inner-icon="mdi-ruler"
|
|
></v-text-field>
|
|
</v-col>
|
|
<v-col cols="12" md="3">
|
|
<v-text-field
|
|
v-model="interest.Width"
|
|
label="Width"
|
|
variant="outlined"
|
|
density="comfortable"
|
|
prepend-inner-icon="mdi-arrow-expand-horizontal"
|
|
></v-text-field>
|
|
</v-col>
|
|
<v-col cols="12" md="3">
|
|
<v-text-field
|
|
v-model="interest.Depth"
|
|
label="Depth"
|
|
variant="outlined"
|
|
density="comfortable"
|
|
prepend-inner-icon="mdi-arrow-expand-vertical"
|
|
></v-text-field>
|
|
</v-col>
|
|
</v-row>
|
|
</v-card-text>
|
|
</v-card>
|
|
<!-- Sales Pipeline Status Section -->
|
|
<v-card 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-chart-timeline-variant</v-icon>
|
|
Sales Pipeline Status
|
|
</v-card-title>
|
|
<v-card-text class="pt-2">
|
|
<v-row dense>
|
|
<v-col cols="12">
|
|
<v-select
|
|
v-model="interest['Sales Process Level']"
|
|
label="Current Sales Level"
|
|
variant="outlined"
|
|
density="comfortable"
|
|
:items="InterestSalesProcessLevelFlow"
|
|
prepend-inner-icon="mdi-chart-line"
|
|
>
|
|
<template v-slot:selection="{ item }">
|
|
<v-chip
|
|
:color="getSalesLevelColor(item.value)"
|
|
size="small"
|
|
class="text-white"
|
|
>
|
|
{{ item.value }}
|
|
</v-chip>
|
|
</template>
|
|
<template v-slot:item="{ item, props }">
|
|
<v-list-item
|
|
v-bind="props"
|
|
:title="item.value"
|
|
>
|
|
<template v-slot:prepend>
|
|
<v-icon :color="getSalesLevelColor(item.value)">
|
|
mdi-circle
|
|
</v-icon>
|
|
</template>
|
|
<template v-slot:title>
|
|
<span :style="{ color: getSalesLevelColor(item.value) }">
|
|
{{ item.value }}
|
|
</span>
|
|
</template>
|
|
</v-list-item>
|
|
</template>
|
|
</v-select>
|
|
<div class="mt-4">
|
|
<v-progress-linear
|
|
:model-value="(currentStep + 1) / InterestSalesProcessLevelFlow.length * 100"
|
|
height="10"
|
|
rounded
|
|
color="blue"
|
|
/>
|
|
<div class="text-caption text-center mt-2">
|
|
Progress: {{ currentStep + 1 }} of {{ InterestSalesProcessLevelFlow.length }} stages
|
|
</div>
|
|
</div>
|
|
</v-col>
|
|
</v-row>
|
|
</v-card-text>
|
|
</v-card>
|
|
|
|
<!-- Berth & Sales Information Section -->
|
|
<v-card 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-anchor</v-icon>
|
|
Berth Information
|
|
</v-card-title>
|
|
<v-card-text class="pt-2">
|
|
<v-row dense>
|
|
<v-col cols="12" md="4">
|
|
<v-text-field
|
|
v-model="interest['Berth Size Desired']"
|
|
label="Berth Size Desired"
|
|
variant="outlined"
|
|
density="comfortable"
|
|
prepend-inner-icon="mdi-tape-measure"
|
|
></v-text-field>
|
|
</v-col>
|
|
<v-col cols="12" md="4">
|
|
<v-select
|
|
v-model="interest['Lead Category']"
|
|
label="Lead Category"
|
|
variant="outlined"
|
|
density="comfortable"
|
|
:items="InterestLeadCategoryFlow"
|
|
prepend-inner-icon="mdi-tag"
|
|
></v-select>
|
|
</v-col>
|
|
<v-col cols="12" md="6">
|
|
<v-autocomplete
|
|
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-model="selectedBerths"
|
|
:items="groupedBerths"
|
|
:item-title="(item) => item.isDivider ? '' : item['Mooring Number']"
|
|
:item-value="(item) => item.isDivider ? null : item.Id"
|
|
label="Berths"
|
|
variant="outlined"
|
|
density="comfortable"
|
|
multiple
|
|
chips
|
|
closable-chips
|
|
:loading="loadingBerths"
|
|
prepend-inner-icon="mdi-dock-window"
|
|
@update:model-value="updateBerths"
|
|
>
|
|
<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-row>
|
|
</v-card-text>
|
|
</v-card>
|
|
|
|
<!-- Process Status Section -->
|
|
<v-card 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-progress-check</v-icon>
|
|
Process Status
|
|
</v-card-title>
|
|
<v-card-text class="pt-2">
|
|
<v-row dense>
|
|
<v-col cols="12">
|
|
<v-row dense>
|
|
<v-col cols="12" md="2">
|
|
<v-select
|
|
v-model="interest['EOI Status']"
|
|
label="EOI Status"
|
|
variant="outlined"
|
|
density="comfortable"
|
|
:items="EOIStatusFlow"
|
|
prepend-inner-icon="mdi-file-document-outline"
|
|
></v-select>
|
|
</v-col>
|
|
<v-col cols="12" md="2">
|
|
<v-select
|
|
v-model="interest['Berth Info Sent Status']"
|
|
label="Berth Info Sent"
|
|
variant="outlined"
|
|
density="comfortable"
|
|
:items="BerthInfoSentStatusFlow"
|
|
prepend-inner-icon="mdi-information-outline"
|
|
></v-select>
|
|
</v-col>
|
|
<v-col cols="12" md="2">
|
|
<v-select
|
|
v-model="interest['Contract Sent Status']"
|
|
label="Contract Sent"
|
|
variant="outlined"
|
|
density="comfortable"
|
|
:items="ContractSentStatusFlow"
|
|
prepend-inner-icon="mdi-email-send"
|
|
></v-select>
|
|
</v-col>
|
|
<v-col cols="12" md="3">
|
|
<v-select
|
|
v-model="interest['Deposit 10% Status']"
|
|
label="Deposit 10%"
|
|
variant="outlined"
|
|
density="comfortable"
|
|
:items="Deposit10PercentStatusFlow"
|
|
prepend-inner-icon="mdi-cash"
|
|
></v-select>
|
|
</v-col>
|
|
<v-col cols="12" md="3">
|
|
<v-select
|
|
v-model="interest['Contract Status']"
|
|
label="Contract Status"
|
|
variant="outlined"
|
|
density="comfortable"
|
|
:items="ContractStatusFlow"
|
|
prepend-inner-icon="mdi-file-document"
|
|
></v-select>
|
|
</v-col>
|
|
</v-row>
|
|
</v-col>
|
|
</v-row>
|
|
</v-card-text>
|
|
</v-card>
|
|
|
|
<!-- Additional Information Section -->
|
|
<v-card 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-information</v-icon>
|
|
Additional Information
|
|
</v-card-title>
|
|
<v-card-text class="pt-2">
|
|
<v-row dense>
|
|
<v-col cols="12" md="4">
|
|
<v-text-field
|
|
v-model="interest.Source"
|
|
label="Source"
|
|
variant="outlined"
|
|
density="comfortable"
|
|
prepend-inner-icon="mdi-source-branch"
|
|
></v-text-field>
|
|
</v-col>
|
|
<v-col cols="12" md="8">
|
|
<v-text-field
|
|
v-model="interest['Extra Comments']"
|
|
label="Extra Comments"
|
|
variant="outlined"
|
|
density="comfortable"
|
|
prepend-inner-icon="mdi-comment-text"
|
|
></v-text-field>
|
|
</v-col>
|
|
<v-col
|
|
cols="12"
|
|
v-if="
|
|
interest['EOI Document'] &&
|
|
interest['EOI Document'].length > 0
|
|
"
|
|
>
|
|
<div class="d-flex align-center">
|
|
<v-icon class="mr-2" color="primary"
|
|
>mdi-file-document</v-icon
|
|
>
|
|
<span class="text-subtitle-1 mr-3">EOI Documents:</span>
|
|
<div class="d-flex flex-wrap ga-2">
|
|
<v-chip
|
|
v-for="(doc, index) in interest['EOI Document']"
|
|
:key="index"
|
|
color="primary"
|
|
variant="tonal"
|
|
prepend-icon="mdi-file-pdf-box"
|
|
:href="doc.url"
|
|
target="_blank"
|
|
component="a"
|
|
clickable
|
|
>
|
|
{{ doc.title || `EOI Document ${index + 1}` }}
|
|
</v-chip>
|
|
</div>
|
|
</div>
|
|
</v-col>
|
|
</v-row>
|
|
</v-card-text>
|
|
</v-card>
|
|
|
|
<!-- 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 -->
|
|
<ClientEmailSection
|
|
v-if="interest"
|
|
:interest="interest"
|
|
@email-sent="onInterestUpdated"
|
|
@update="onInterestUpdated"
|
|
/>
|
|
</v-form>
|
|
</div>
|
|
</v-card-text>
|
|
</v-card>
|
|
</v-dialog>
|
|
</template>
|
|
|
|
<script lang="ts" setup>
|
|
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 ClientEmailSection from "./ClientEmailSection.vue";
|
|
import EOISection from "./EOISection.vue";
|
|
import {
|
|
InterestSalesProcessLevelFlow,
|
|
InterestLeadCategoryFlow,
|
|
ContactMethodPreferredFlow,
|
|
EOIStatusFlow,
|
|
BerthInfoSentStatusFlow,
|
|
ContractSentStatusFlow,
|
|
Deposit10PercentStatusFlow,
|
|
ContractStatusFlow,
|
|
} from "@/utils/types";
|
|
|
|
interface Props {
|
|
modelValue: boolean;
|
|
selectedInterest: Interest | null;
|
|
}
|
|
|
|
interface Emits {
|
|
(e: "update:modelValue", value: boolean): void;
|
|
(e: "save", interest: Interest): void;
|
|
(e: "requestMoreInfoToSales", interest: Interest): void;
|
|
(e: "requestMoreInformation", interest: Interest): void;
|
|
(e: "eoiSendToSales", interest: Interest): void;
|
|
}
|
|
|
|
const props = defineProps<Props>();
|
|
const emit = defineEmits<Emits>();
|
|
|
|
const { mobile } = useDisplay();
|
|
|
|
const user = useDirectusUser();
|
|
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);
|
|
const isRequestingMoreInformation = ref(false);
|
|
const isSendingEOI = ref(false);
|
|
const isDeleting = ref(false);
|
|
|
|
// Berths related data
|
|
const availableBerths = ref<Berth[]>([]);
|
|
const loadingBerths = ref(false);
|
|
const selectedBerths = ref<number[]>([]);
|
|
const selectedBerthRecommendations = ref<number[]>([]);
|
|
const originalBerths = ref<number[]>([]);
|
|
const originalBerthRecommendations = ref<number[]>([]);
|
|
|
|
// Store the debounced functions
|
|
let autoSave: any = null;
|
|
let debouncedSaveInterest: any = null;
|
|
let debouncedDeleteInterest: any = null;
|
|
|
|
// Initialize debounced functions
|
|
const initializeDebouncedFunctions = () => {
|
|
// Auto-save function (debounced)
|
|
autoSave = debounce(async () => {
|
|
if (!hasUnsavedChanges.value || !interest.value || isSaving.value) return;
|
|
|
|
console.log('Auto-saving interest...');
|
|
await saveInterest(true); // Pass true to indicate auto-save
|
|
}, 2000); // 2 second delay
|
|
|
|
// Debounced manual save
|
|
debouncedSaveInterest = debounce(async () => {
|
|
await saveInterest();
|
|
}, 300); // 300ms delay to prevent multiple clicks
|
|
|
|
// Debounced delete
|
|
debouncedDeleteInterest = debounce(async () => {
|
|
await deleteInterest();
|
|
}, 300); // 300ms delay to prevent multiple clicks
|
|
};
|
|
|
|
// Initialize on component creation
|
|
initializeDebouncedFunctions();
|
|
|
|
// Watch for changes to trigger auto-save
|
|
watch(
|
|
() => interest.value,
|
|
(newValue, oldValue) => {
|
|
if (newValue && oldValue && JSON.stringify(newValue) !== JSON.stringify(oldValue)) {
|
|
hasUnsavedChanges.value = true;
|
|
// Cancel any pending saves
|
|
if (debouncedSaveInterest) debouncedSaveInterest.cancel();
|
|
if (autoSave) autoSave();
|
|
}
|
|
},
|
|
{ deep: true }
|
|
);
|
|
|
|
// Watch yacht information and berth size to auto-set Sales Process Level
|
|
watch(
|
|
() => {
|
|
if (!interest.value) return null;
|
|
return {
|
|
yachtName: interest.value['Yacht Name'],
|
|
length: interest.value.Length,
|
|
width: interest.value.Width,
|
|
depth: interest.value.Depth,
|
|
berthSize: interest.value['Berth Size Desired']
|
|
};
|
|
},
|
|
(newValues) => {
|
|
if (!newValues || !interest.value) return;
|
|
|
|
// Check if any yacht information is provided or berth size is provided
|
|
const hasYachtInfo = !!(newValues.yachtName || newValues.length || newValues.width || newValues.depth);
|
|
const hasBerthSize = !!newValues.berthSize;
|
|
|
|
if ((hasYachtInfo || hasBerthSize) && interest.value['Sales Process Level'] === 'General Qualified Interest') {
|
|
// Auto-set to Specific Qualified Interest
|
|
interest.value['Sales Process Level'] = 'Specific Qualified Interest';
|
|
console.log('Auto-setting Sales Process Level to Specific Qualified Interest');
|
|
}
|
|
},
|
|
{ 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();
|
|
} else {
|
|
interest.value = null;
|
|
selectedBerths.value = [];
|
|
selectedBerthRecommendations.value = [];
|
|
originalBerths.value = [];
|
|
originalBerthRecommendations.value = [];
|
|
}
|
|
},
|
|
{ immediate: true }
|
|
);
|
|
|
|
// Computed property for v-model binding
|
|
const isOpen = computed({
|
|
get: () => props.modelValue,
|
|
set: (value: boolean) => emit("update:modelValue", value),
|
|
});
|
|
|
|
const currentStep = computed(() => {
|
|
return InterestSalesProcessLevelFlow.indexOf(
|
|
interest.value?.["Sales Process Level"] || ""
|
|
);
|
|
});
|
|
|
|
const hasEOILinks = computed(() => {
|
|
if (!interest.value) return false;
|
|
return !!(
|
|
(interest.value as any)['EOI Client Link'] ||
|
|
(interest.value as any)['EOI Oscar Link'] ||
|
|
(interest.value as any)['EOI David Link']
|
|
);
|
|
});
|
|
|
|
const closeModal = () => {
|
|
isOpen.value = false;
|
|
};
|
|
|
|
const handleFormSubmit = () => {
|
|
if (debouncedSaveInterest) {
|
|
debouncedSaveInterest();
|
|
} else {
|
|
saveInterest();
|
|
}
|
|
};
|
|
|
|
const saveInterest = async (isAutoSave = false) => {
|
|
if (interest.value) {
|
|
isSaving.value = true;
|
|
try {
|
|
// Create a copy of the interest data without the Berths and Berth Recommendations fields
|
|
const dataToSave = { ...interest.value };
|
|
delete (dataToSave as any).Berths;
|
|
delete (dataToSave as any)["Berth Recommendations"];
|
|
|
|
// Call the update-interest API
|
|
await $fetch("/api/update-interest", {
|
|
method: "POST",
|
|
headers: {
|
|
"x-tag": user.value?.email ? "094ut234" : "pjnvü1230",
|
|
},
|
|
body: {
|
|
id: interest.value.Id.toString(),
|
|
data: dataToSave,
|
|
},
|
|
});
|
|
|
|
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);
|
|
if (!isAutoSave) {
|
|
toast.error("Failed to save interest. Please try again.");
|
|
}
|
|
} finally {
|
|
isSaving.value = false;
|
|
}
|
|
}
|
|
};
|
|
|
|
const requestMoreInfoToSales = async () => {
|
|
if (interest.value) {
|
|
isRequestingMoreInfo.value = true;
|
|
try {
|
|
// Call the request-more-info-to-sales API
|
|
await $fetch("/api/request-more-info-to-sales", {
|
|
method: "POST",
|
|
headers: {
|
|
"x-tag": user.value?.email ? "094ut234" : "pjnvü1230",
|
|
},
|
|
body: {
|
|
interestId: interest.value.Id.toString(),
|
|
},
|
|
});
|
|
|
|
toast.success("Request More Info - To Sales sent successfully!");
|
|
emit("requestMoreInfoToSales", interest.value);
|
|
} catch (error) {
|
|
console.error("Failed to send request:", error);
|
|
toast.error("Failed to send request. Please try again.");
|
|
} finally {
|
|
isRequestingMoreInfo.value = false;
|
|
}
|
|
}
|
|
};
|
|
|
|
const requestMoreInformation = async () => {
|
|
if (interest.value) {
|
|
isRequestingMoreInformation.value = true;
|
|
try {
|
|
// Call the request-more-information API
|
|
await $fetch("/api/request-more-information", {
|
|
method: "POST",
|
|
headers: {
|
|
"x-tag": user.value?.email ? "094ut234" : "pjnvü1230",
|
|
},
|
|
body: {
|
|
interestId: interest.value.Id.toString(),
|
|
},
|
|
});
|
|
|
|
toast.success("Request More Information sent successfully!");
|
|
emit("requestMoreInformation", interest.value);
|
|
} catch (error) {
|
|
console.error("Failed to send request:", error);
|
|
toast.error("Failed to send request. Please try again.");
|
|
} finally {
|
|
isRequestingMoreInformation.value = false;
|
|
}
|
|
}
|
|
};
|
|
|
|
const eoiSendToSales = async () => {
|
|
if (interest.value) {
|
|
isSendingEOI.value = true;
|
|
try {
|
|
// Call the eoi-send-to-sales API
|
|
await $fetch("/api/eoi-send-to-sales", {
|
|
method: "POST",
|
|
headers: {
|
|
"x-tag": user.value?.email ? "094ut234" : "pjnvü1230",
|
|
},
|
|
body: {
|
|
interestId: interest.value.Id.toString(),
|
|
},
|
|
});
|
|
|
|
toast.success("EOI Send to Sales sent successfully!");
|
|
emit("eoiSendToSales", interest.value);
|
|
} catch (error) {
|
|
console.error("Failed to send EOI:", error);
|
|
toast.error("Failed to send EOI. Please try again.");
|
|
} finally {
|
|
isSendingEOI.value = false;
|
|
}
|
|
}
|
|
};
|
|
|
|
// Group berths by first letter
|
|
const groupedBerths = computed(() => {
|
|
const grouped: Record<string, Berth[]> = {};
|
|
const sortedBerths = [...availableBerths.value].sort((a, b) =>
|
|
(a['Mooring Number'] || '').localeCompare(b['Mooring Number'] || '')
|
|
);
|
|
|
|
sortedBerths.forEach(berth => {
|
|
const firstLetter = (berth['Mooring Number'] || '').charAt(0).toUpperCase();
|
|
if (!grouped[firstLetter]) {
|
|
grouped[firstLetter] = [];
|
|
}
|
|
grouped[firstLetter].push(berth);
|
|
});
|
|
|
|
// Create flat array with dividers
|
|
const result: any[] = [];
|
|
Object.keys(grouped).sort().forEach((letter, index) => {
|
|
if (index > 0) {
|
|
// Add divider
|
|
result.push({ isDivider: true, letter });
|
|
}
|
|
result.push(...grouped[letter]);
|
|
});
|
|
|
|
return result;
|
|
});
|
|
|
|
// Load all available berths
|
|
const loadAvailableBerths = async () => {
|
|
loadingBerths.value = true;
|
|
try {
|
|
const response = await $fetch<{ list: Berth[] }>("/api/get-berths", {
|
|
headers: {
|
|
"x-tag": user.value?.email ? "094ut234" : "pjnvü1230",
|
|
},
|
|
});
|
|
availableBerths.value = response.list || [];
|
|
} catch (error) {
|
|
console.error("Failed to load berths:", error);
|
|
} finally {
|
|
loadingBerths.value = false;
|
|
}
|
|
};
|
|
|
|
// Load linked berths for the current interest
|
|
const loadLinkedBerths = async () => {
|
|
if (!interest.value) return;
|
|
|
|
try {
|
|
// Load berths
|
|
const berthsResponse = await $fetch<{ list: Array<{ Id: number }> }>(
|
|
"/api/get-interest-berths",
|
|
{
|
|
headers: {
|
|
"x-tag": user.value?.email ? "094ut234" : "pjnvü1230",
|
|
},
|
|
params: {
|
|
interestId: interest.value.Id,
|
|
linkType: "berths",
|
|
},
|
|
}
|
|
);
|
|
|
|
// Load berth recommendations
|
|
const recommendationsResponse = await $fetch<{
|
|
list: Array<{ Id: number }>;
|
|
}>("/api/get-interest-berths", {
|
|
headers: {
|
|
"x-tag": user.value?.email ? "094ut234" : "pjnvü1230",
|
|
},
|
|
params: {
|
|
interestId: interest.value.Id,
|
|
linkType: "recommendations",
|
|
},
|
|
});
|
|
|
|
selectedBerths.value = (berthsResponse.list || []).map((b) => b.Id);
|
|
selectedBerthRecommendations.value = (
|
|
recommendationsResponse.list || []
|
|
).map((b) => b.Id);
|
|
|
|
// Store original values to detect changes
|
|
originalBerths.value = [...selectedBerths.value];
|
|
originalBerthRecommendations.value = [
|
|
...selectedBerthRecommendations.value,
|
|
];
|
|
} catch (error) {
|
|
console.error("Failed to load linked berths:", error);
|
|
}
|
|
};
|
|
|
|
// Update berths when selection changes
|
|
const updateBerths = async (newBerths: number[]) => {
|
|
if (!interest.value) return;
|
|
|
|
try {
|
|
// Find berths to add and remove
|
|
const toAdd = newBerths.filter((id) => !originalBerths.value.includes(id));
|
|
const toRemove = originalBerths.value.filter(
|
|
(id) => !newBerths.includes(id)
|
|
);
|
|
|
|
// Link new berths
|
|
if (toAdd.length > 0) {
|
|
await $fetch("/api/link-berths-to-interest", {
|
|
method: "POST",
|
|
headers: {
|
|
"x-tag": user.value?.email ? "094ut234" : "pjnvü1230",
|
|
},
|
|
body: {
|
|
interestId: interest.value.Id,
|
|
berthIds: toAdd,
|
|
},
|
|
});
|
|
}
|
|
|
|
// Unlink removed berths
|
|
if (toRemove.length > 0) {
|
|
await $fetch("/api/unlink-berths-from-interest", {
|
|
method: "POST",
|
|
headers: {
|
|
"x-tag": user.value?.email ? "094ut234" : "pjnvü1230",
|
|
},
|
|
body: {
|
|
interestId: interest.value.Id,
|
|
berthIds: toRemove,
|
|
},
|
|
});
|
|
}
|
|
|
|
// Update original values
|
|
originalBerths.value = [...newBerths];
|
|
} catch (error) {
|
|
console.error("Failed to update berths:", error);
|
|
toast.error("Failed to update berths. Please try again.");
|
|
// Revert to original values on error
|
|
selectedBerths.value = [...originalBerths.value];
|
|
}
|
|
};
|
|
|
|
// Update berth recommendations when selection changes
|
|
const updateBerthRecommendations = async (newRecommendations: number[]) => {
|
|
if (!interest.value) return;
|
|
|
|
try {
|
|
// Find recommendations to add and remove
|
|
const toAdd = newRecommendations.filter(
|
|
(id) => !originalBerthRecommendations.value.includes(id)
|
|
);
|
|
const toRemove = originalBerthRecommendations.value.filter(
|
|
(id) => !newRecommendations.includes(id)
|
|
);
|
|
|
|
// Link new recommendations
|
|
if (toAdd.length > 0) {
|
|
await $fetch("/api/link-berth-recommendations-to-interest", {
|
|
method: "POST",
|
|
headers: {
|
|
"x-tag": user.value?.email ? "094ut234" : "pjnvü1230",
|
|
},
|
|
body: {
|
|
interestId: interest.value.Id,
|
|
berthIds: toAdd,
|
|
},
|
|
});
|
|
}
|
|
|
|
// Unlink removed recommendations
|
|
if (toRemove.length > 0) {
|
|
await $fetch("/api/unlink-berth-recommendations-from-interest", {
|
|
method: "POST",
|
|
headers: {
|
|
"x-tag": user.value?.email ? "094ut234" : "pjnvü1230",
|
|
},
|
|
body: {
|
|
interestId: interest.value.Id,
|
|
berthIds: toRemove,
|
|
},
|
|
});
|
|
}
|
|
|
|
// Update original values
|
|
originalBerthRecommendations.value = [...newRecommendations];
|
|
} catch (error) {
|
|
console.error("Failed to update berth recommendations:", error);
|
|
toast.error("Failed to update berth recommendations. Please try again.");
|
|
// Revert to original values on error
|
|
selectedBerthRecommendations.value = [
|
|
...originalBerthRecommendations.value,
|
|
];
|
|
}
|
|
};
|
|
|
|
// Format date helper function
|
|
const formatDate = (dateString: string | null | undefined) => {
|
|
if (!dateString) return "";
|
|
|
|
try {
|
|
let date: Date;
|
|
|
|
// Check if it's an ISO date string (e.g., "2025-06-09T22:58:47.731Z")
|
|
if (dateString.includes('T') || dateString.includes('Z')) {
|
|
date = new Date(dateString);
|
|
}
|
|
// Handle DD-MM-YYYY format
|
|
else if (dateString.match(/^\d{2}-\d{2}-\d{4}$/)) {
|
|
const [day, month, year] = dateString.split("-");
|
|
date = new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
|
|
}
|
|
// Handle YYYY-MM-DD format
|
|
else if (dateString.match(/^\d{4}-\d{2}-\d{2}$/)) {
|
|
date = new Date(dateString);
|
|
}
|
|
// Fallback to direct parsing
|
|
else {
|
|
date = new Date(dateString);
|
|
}
|
|
|
|
// Check if date is valid
|
|
if (isNaN(date.getTime())) {
|
|
return dateString;
|
|
}
|
|
|
|
// Format date in DD/MM/YYYY HH:mm format
|
|
const day = date.getDate().toString().padStart(2, '0');
|
|
const month = (date.getMonth() + 1).toString().padStart(2, '0');
|
|
const year = date.getFullYear();
|
|
const hours = date.getHours().toString().padStart(2, '0');
|
|
const minutes = date.getMinutes().toString().padStart(2, '0');
|
|
|
|
// Include time if it's not midnight
|
|
if (hours !== '00' || minutes !== '00') {
|
|
return `${day}/${month}/${year} ${hours}:${minutes}`;
|
|
}
|
|
|
|
return `${day}/${month}/${year}`;
|
|
} catch (error) {
|
|
console.error('Date formatting error:', error, dateString);
|
|
return dateString;
|
|
}
|
|
};
|
|
|
|
// Get color for sales level - matching InterestSalesBadge.vue
|
|
const getSalesLevelColor = (level: string) => {
|
|
const colorMapping = {
|
|
"General Qualified Interest": "blue",
|
|
"Specific Qualified Interest": "green",
|
|
"LOI and NDA Sent": "orange",
|
|
"Signed LOI and NDA": "purple",
|
|
"Made Reservation": "pink",
|
|
"Contract Negotiation": "brown",
|
|
"Contract Negotiations Finalized": "teal",
|
|
"Contract Signed": "indigo",
|
|
};
|
|
|
|
return colorMapping[level as keyof typeof colorMapping] || "grey";
|
|
};
|
|
|
|
// Confirm delete
|
|
const confirmDelete = () => {
|
|
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.`)) {
|
|
deleteInterest();
|
|
}
|
|
};
|
|
|
|
// Delete interest
|
|
const deleteInterest = async () => {
|
|
if (!interest.value || isDeleting.value) return;
|
|
|
|
console.log('[InterestDetailsModal] Starting delete for interest:', interest.value.Id);
|
|
isDeleting.value = true;
|
|
|
|
try {
|
|
const response = await $fetch("/api/delete-interest", {
|
|
method: "POST",
|
|
headers: {
|
|
"x-tag": user.value?.email ? "094ut234" : "pjnvü1230",
|
|
},
|
|
body: {
|
|
id: interest.value.Id.toString(),
|
|
},
|
|
});
|
|
|
|
console.log('[InterestDetailsModal] Delete response:', response);
|
|
toast.success("Interest deleted successfully!");
|
|
closeModal();
|
|
emit("save", interest.value); // Trigger refresh
|
|
} 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;
|
|
}
|
|
};
|
|
|
|
// Copy to clipboard function
|
|
const copyToClipboard = async (text: string, recipient: string) => {
|
|
try {
|
|
await navigator.clipboard.writeText(text);
|
|
toast.success(`${recipient} link copied to clipboard!`);
|
|
} catch (err) {
|
|
console.error('Failed to copy text: ', err);
|
|
toast.error('Failed to copy link to clipboard');
|
|
}
|
|
};
|
|
|
|
// 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
|
|
if (interest.value) {
|
|
try {
|
|
const updatedInterest = await $fetch<Interest>(`/api/get-interest-by-id`, {
|
|
headers: {
|
|
"x-tag": user.value?.email ? "094ut234" : "pjnvü1230",
|
|
},
|
|
params: {
|
|
id: interest.value.Id,
|
|
},
|
|
});
|
|
|
|
if (updatedInterest) {
|
|
interest.value = { ...updatedInterest };
|
|
emit("save", interest.value); // Trigger parent refresh
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to reload interest:', error);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Load berths when component mounts
|
|
onMounted(() => {
|
|
loadAvailableBerths();
|
|
});
|
|
|
|
// Cleanup on unmount
|
|
onUnmounted(() => {
|
|
if (autoSaveTimer.value) {
|
|
clearTimeout(autoSaveTimer.value);
|
|
}
|
|
// Cancel any pending auto-save
|
|
autoSave.cancel();
|
|
});
|
|
</script>
|