feat: update

This commit is contained in:
Ron 2025-06-03 18:57:08 +03:00
parent b4313dd815
commit b3651e7f43
17 changed files with 2066 additions and 490 deletions

View File

@ -0,0 +1,757 @@
<template>
<v-dialog v-model="isOpen" fullscreen hide-overlay transition="dialog-bottom-transition">
<v-card>
<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 Details
</v-toolbar-title>
<v-spacer></v-spacer>
<v-toolbar-items>
<v-btn
@click="requestMoreInfoToSales"
variant="text"
:loading="isRequestingMoreInfo"
:disabled="isRequestingMoreInfo || isRequestingMoreInformation || isSendingEOI"
>
<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"
>
<v-icon start>mdi-email-outline</v-icon>
Request Info
</v-btn>
<v-btn
@click="eoiSendToSales"
variant="text"
:loading="isSendingEOI"
:disabled="isRequestingMoreInfo || isRequestingMoreInformation || isSendingEOI"
>
<v-icon start>mdi-send</v-icon>
EOI to Sales
</v-btn>
<v-btn
variant="flat"
color="success"
size="large"
@click="saveInterest"
:loading="isSaving"
:disabled="isSaving"
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">
<v-stepper
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 ? 'primary' : '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"
prepend-icon="mdi-email-fast"
>
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>
<v-form @submit.prevent="saveInterest">
<!-- 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">
<v-text-field
v-model="interest['Phone Number']"
label="Phone Number"
variant="outlined"
density="comfortable"
prepend-inner-icon="mdi-phone"
></v-text-field>
</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-text-field
v-model="interest['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-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>
<!-- 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 & Sales 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['Sales Process Level']"
label="Sales Process Level"
variant="outlined"
density="comfortable"
:items="InterestSalesProcessLevelFlow"
prepend-inner-icon="mdi-chart-line"
></v-select>
</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="availableBerths"
:item-title="item => item['Mooring Number']"
:item-value="item => 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: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="availableBerths"
:item-title="item => item['Mooring Number']"
:item-value="item => 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: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>
<!-- 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>
</v-form>
</v-card-text>
</v-card>
</v-dialog>
</template>
<script lang="ts" setup>
import { ref, computed, watch, onMounted } from "vue";
import type { Interest, Berth } from "@/utils/types";
import {
InterestSalesProcessLevelFlow,
InterestLeadCategoryFlow,
ContactMethodPreferredFlow,
} 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 user = useDirectusUser();
// Local copy of the interest for editing
const interest = ref<Interest | null>(null);
// Loading states for buttons
const isSaving = ref(false);
const isRequestingMoreInfo = ref(false);
const isRequestingMoreInformation = ref(false);
const isSendingEOI = 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[]>([]);
// Sync the local copy with the prop
watch(() => props.selectedInterest, async (newInterest) => {
if (newInterest) {
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 closeModal = () => {
isOpen.value = false;
};
const saveInterest = async () => {
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.Berths;
delete dataToSave['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
}
});
alert("Interest saved successfully!");
emit('save', interest.value);
closeModal();
} catch (error) {
console.error("Failed to save interest:", error);
alert("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()
}
});
alert("Request More Info - To Sales sent successfully!");
emit('requestMoreInfoToSales', interest.value);
} catch (error) {
console.error("Failed to send request:", error);
alert("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()
}
});
alert("Request More Information sent successfully!");
emit('requestMoreInformation', interest.value);
} catch (error) {
console.error("Failed to send request:", error);
alert("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()
}
});
alert("EOI Send to Sales sent successfully!");
emit('eoiSendToSales', interest.value);
} catch (error) {
console.error("Failed to send EOI:", error);
alert("Failed to send EOI. Please try again.");
} finally {
isSendingEOI.value = false;
}
}
};
// 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);
alert("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);
alert("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 {
// Handle DD-MM-YYYY format
if (dateString.includes('-')) {
const parts = dateString.split('-');
if (parts.length === 3) {
const [day, month, year] = parts;
const date = new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
}
}
// Fallback to direct date parsing
const date = new Date(dateString);
if (!isNaN(date.getTime())) {
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
}
return dateString;
} catch (error) {
return dateString;
}
};
// Load berths when component mounts
onMounted(() => {
loadAvailableBerths();
});
</script>

View File

@ -0,0 +1,438 @@
<template>
<div>
<v-container fluid>
<!-- Header Section -->
<v-row class="mb-6">
<v-col cols="12">
<h1 class="text-h4 font-weight-bold mb-2">
<v-icon class="mr-2" color="primary">mdi-account-group</v-icon>
Interest List
</h1>
<p class="text-subtitle-1 text-grey-darken-1">
Manage and track all potential client interests
</p>
</v-col>
</v-row>
<!-- Search and Filters Section -->
<v-row class="mb-4">
<v-col cols="12" md="6">
<v-text-field
v-model="search"
label="Search interests..."
placeholder="Search by name, yacht, email, phone..."
prepend-inner-icon="mdi-magnify"
variant="outlined"
density="comfortable"
clearable
hide-details
class="search-field"
>
<template v-slot:append-inner>
<v-fade-transition>
<v-chip
v-if="filteredInterests.length !== interests?.list?.length"
size="small"
color="primary"
variant="tonal"
>
{{ filteredInterests.length }} results
</v-chip>
</v-fade-transition>
</template>
</v-text-field>
</v-col>
<v-col cols="12" md="6" class="d-flex justify-end align-center">
<v-chip-group
v-model="selectedSalesLevel"
selected-class="text-primary"
column
>
<v-chip
filter
variant="outlined"
size="small"
value="all"
>
All Levels
</v-chip>
<v-chip
filter
variant="outlined"
size="small"
value="qualified"
>
Qualified
</v-chip>
<v-chip
filter
variant="outlined"
size="small"
value="loi"
>
LOI Sent
</v-chip>
<v-chip
filter
variant="outlined"
size="small"
value="reserved"
>
Reserved
</v-chip>
</v-chip-group>
</v-col>
</v-row>
<!-- Data Table -->
<v-card elevation="0" class="rounded-lg">
<v-data-table
:headers="headers"
:items="filteredInterests"
:search="search"
:sort-by="[{ key: 'Created At', order: 'desc' }, { key: 'Full Name', order: 'asc' }]"
must-sort
hover
:loading="loading"
loading-text="Loading interests..."
:items-per-page="50"
:items-per-page-options="[10, 20, 50, 100]"
class="modern-table"
>
<template #item="{ item }">
<tr @click="handleRowClick(item)" class="table-row">
<td>
<div class="d-flex align-center">
<v-avatar size="32" color="primary" class="mr-3">
<span class="text-white text-caption font-weight-bold">
{{ getInitials(item["Full Name"]) }}
</span>
</v-avatar>
<div>
<div class="font-weight-medium">{{ item["Full Name"] }}</div>
<div class="text-caption text-grey-darken-1">{{ item["Email Address"] }}</div>
</div>
</div>
</td>
<td>
<div v-if="item['Yacht Name']" class="d-flex align-center">
<v-icon size="small" class="mr-2" color="blue-darken-2">mdi-sail-boat</v-icon>
<span>{{ item["Yacht Name"] }}</span>
</div>
<span v-else class="text-grey-darken-1"></span>
</td>
<td>
<v-chip
v-if="item['Berth Number']"
size="small"
color="deep-purple"
variant="tonal"
>
{{ item["Berth Number"] }}
</v-chip>
<span v-else class="text-grey-darken-1"></span>
</td>
<td>
<InterestSalesBadge
:salesProcessLevel="item['Sales Process Level']"
/>
</td>
<td>
<div v-if="item['Phone Number']" class="d-flex align-center">
<v-icon size="small" class="mr-2" color="green-darken-2">mdi-phone</v-icon>
<span>{{ item["Phone Number"] }}</span>
</div>
<span v-else class="text-grey-darken-1"></span>
</td>
<td><LeadCategoryBadge :leadCategory="item['Lead Category']" /></td>
<td>
<div class="text-caption">
<div>{{ formatDate(item["Created At"]) }}</div>
<div class="text-grey-darken-1">{{ getRelativeTime(item["Created At"]) }}</div>
</div>
</td>
<td>
<v-tooltip v-if="item['Extra Comments']" location="bottom">
<template #activator="{ props }">
<v-chip
v-bind="props"
size="small"
color="orange"
variant="tonal"
>
<v-icon start size="small">mdi-comment-text</v-icon>
Note
</v-chip>
</template>
<span>{{ item["Extra Comments"] }}</span>
</v-tooltip>
<span v-else class="text-grey-darken-1"></span>
</td>
</tr>
</template>
</v-data-table>
</v-card>
</v-container>
<!-- Interest Details Modal -->
<InterestDetailsModal
v-model="showModal"
:selected-interest="selectedInterest"
@save="handleSaveInterest"
@request-more-info-to-sales="handleRequestMoreInfoToSales"
@request-more-information="handleRequestMoreInformation"
@eoi-send-to-sales="handleEoiSendToSales"
/>
</div>
</template>
<script lang="ts" setup>
import LeadCategoryBadge from "~/components/LeadCategoryBadge.vue";
import InterestSalesBadge from "~/components/InterestSalesBadge.vue";
import InterestDetailsModal from "~/components/InterestDetailsModal.vue";
import { useFetch } from "#app";
import { ref, computed } from "vue";
import type { Interest } from "@/utils/types";
useHead({
title: "Interest List",
});
const user = useDirectusUser();
const router = useRouter();
const loading = ref(true);
const showModal = ref(false);
const selectedInterest = ref<Interest | null>(null);
const selectedSalesLevel = ref('all');
const { data: interests, refresh } = useFetch<InterestsResponse>("/api/get-interests", {
headers: {
"x-tag": user.value?.email ? "094ut234" : "pjnvü1230",
},
onResponse() {
loading.value = false;
},
onResponseError() {
loading.value = false;
},
});
const search = ref("");
const handleRowClick = (interest: Interest) => {
selectedInterest.value = { ...interest };
showModal.value = true;
};
// Event handlers for the modal
const handleSaveInterest = async (interest: Interest) => {
// Refresh the interests data to reflect the updates
loading.value = true;
await refresh();
loading.value = false;
};
const handleRequestMoreInfoToSales = (interest: Interest) => {
// The modal component already handles the API call
// You can add additional logic here if needed
};
const handleRequestMoreInformation = (interest: Interest) => {
// The modal component already handles the API call
// You can add additional logic here if needed
};
const handleEoiSendToSales = (interest: Interest) => {
// The modal component already handles the API call
// You can add additional logic here if needed
};
const headers = [
{ title: "Contact", key: "Full Name", sortable: true, width: "25%" },
{ title: "Yacht", key: "Yacht Name", sortable: true },
{ title: "Berth", key: "Berth Number", sortable: true },
{ title: "Status", key: "Sales Process Level", sortable: true },
{ title: "Phone", key: "Phone Number", sortable: false },
{ title: "Category", key: "Lead Category", sortable: true },
{ title: "Created", key: "Created At", sortable: true },
{ title: "Notes", key: "Extra Comments", sortable: false },
];
const formatDate = (dateString: string) => {
if (!dateString) return "-";
try {
// Handle DD-MM-YYYY format
const parts = dateString.split("-");
if (parts.length === 3) {
const day = parseInt(parts[0], 10);
const month = parseInt(parts[1], 10) - 1; // Month is 0-indexed in JavaScript
const year = parseInt(parts[2], 10);
const date = new Date(year, month, day);
// Check if the date is valid
if (isNaN(date.getTime())) {
console.warn("Invalid date format:", dateString);
return "-";
}
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
} else {
// Fallback to standard Date parsing
const date = new Date(dateString);
if (isNaN(date.getTime())) {
console.warn("Invalid date format:", dateString);
return "-";
}
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
}
} catch (error) {
console.error("Error formatting date:", dateString, error);
return "-";
}
};
const filteredInterests = computed(() => {
if (!interests.value?.list) return [];
let filtered = interests.value.list;
// Apply sales level filter
if (selectedSalesLevel.value !== 'all') {
filtered = filtered.filter((item) => {
const level = item['Sales Process Level']?.toLowerCase() || '';
switch (selectedSalesLevel.value) {
case 'qualified':
return level.includes('qualified');
case 'loi':
return level.includes('loi');
case 'reserved':
return level.includes('reservation') || level.includes('reserved');
default:
return true;
}
});
}
// Apply search filter
if (search.value) {
const searchLower = search.value.toLowerCase();
filtered = filtered.filter((item) => {
return Object.values(item).some((value) =>
String(value).toLowerCase().includes(searchLower)
);
});
}
return filtered;
});
// Helper function to get initials
const getInitials = (name: string) => {
if (!name) return '?';
const parts = name.split(' ');
if (parts.length >= 2) {
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
return name.substring(0, 2).toUpperCase();
};
// Helper function to get relative time
const getRelativeTime = (dateString: string) => {
if (!dateString) return '';
try {
let date: Date;
// Handle DD-MM-YYYY format
if (dateString.includes('-')) {
const parts = dateString.split('-');
if (parts.length === 3) {
const [day, month, year] = parts;
date = new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
} else {
date = new Date(dateString);
}
} else {
date = new Date(dateString);
}
if (isNaN(date.getTime())) return '';
const now = new Date();
const diffTime = Math.abs(now.getTime() - date.getTime());
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
if (diffDays === 0) return 'Today';
if (diffDays === 1) return 'Yesterday';
if (diffDays < 7) return `${diffDays} days ago`;
if (diffDays < 30) return `${Math.floor(diffDays / 7)} weeks ago`;
if (diffDays < 365) return `${Math.floor(diffDays / 30)} months ago`;
return `${Math.floor(diffDays / 365)} years ago`;
} catch (error) {
return '';
}
};
</script>
<style scoped>
.search-field :deep(.v-field) {
transition: all 0.3s ease;
}
.search-field:focus-within :deep(.v-field) {
box-shadow: 0 0 0 2px rgba(25, 118, 210, 0.2);
}
.modern-table :deep(.v-table__wrapper) {
border-radius: 8px;
overflow: hidden;
}
.modern-table :deep(.v-data-table-header__content) {
font-weight: 600;
color: #424242;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.modern-table :deep(tbody tr) {
transition: all 0.2s ease;
}
.modern-table :deep(tbody tr:hover) {
background-color: #f8f9fa;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}
.table-row {
cursor: pointer;
}
.table-row td {
padding: 16px !important;
border-bottom: 1px solid #e0e0e0;
}
.table-row:last-child td {
border-bottom: none;
}
.v-avatar {
font-size: 0.875rem;
}
</style>

View File

@ -1,296 +0,0 @@
<template>
<div>
<v-container>
<v-btn @click="router.back()" variant="text">
<v-icon icon="mdi-arrow-left"></v-icon>
Back to Contacts
</v-btn>
<v-card flat>
<v-card-text>
<v-stepper
v-model="currentStep"
class="mb-6"
variant="flat"
alt-labels
>
<v-stepper-header>
<template
v-for="(level, index) in InterestSalesProcessLevelFlow"
:key="index"
>
<v-stepper-item
:step="index"
:complete="currentStep + 1 > index"
:title="level"
/>
<v-divider
v-if="index < InterestSalesProcessLevelFlow.length - 1"
></v-divider>
</template>
</v-stepper-header>
</v-stepper>
<v-form v-if="interest" @submit.prevent="saveInterest">
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="interest['Full Name']"
label="Full Name"
variant="outlined"
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="interest['Yacht Name']"
label="Yacht Name"
variant="outlined"
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="interest.Length"
label="Length"
variant="outlined"
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="interest.Address"
label="Address"
variant="outlined"
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="interest['Email Address']"
label="Email Address"
variant="outlined"
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-select
v-model="interest['Sales Process Level']"
label="Sales Process Level"
variant="outlined"
:items="InterestSalesProcessLevelFlow"
></v-select>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="interest['Phone Number']"
label="Phone Number"
variant="outlined"
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="interest['Extra Comments']"
label="Extra Comments"
variant="outlined"
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="interest['Berth Size Desired']"
label="Berth Size Desired"
variant="outlined"
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="interest['Berth Recommendations']"
label="Berth Recommendations"
variant="outlined"
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="interest['Date Added']"
label="Date Added"
variant="outlined"
readonly
disabled
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="interest.Width"
label="Width"
variant="outlined"
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="interest.Depth"
label="Depth"
variant="outlined"
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="interest['Created At']"
label="Created At"
variant="outlined"
readonly
disabled
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="interest.Source"
label="Source"
variant="outlined"
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="interest['Place of Residence']"
label="Place of Residence"
variant="outlined"
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-select
v-model="interest['Contact Method Preferred']"
label="Contact Method Preferred"
variant="outlined"
:items="ContactMethodPreferredFlow"
></v-select>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="interest['Request Form Sent']"
label="Request Form Sent"
variant="outlined"
readonly
disabled
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="interest['Berth Number']"
label="Berth Number"
variant="outlined"
></v-text-field>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="interest['EOI Time Sent']"
label="EOI Time Sent"
variant="outlined"
readonly
disabled
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-select
v-model="interest['Lead Category']"
label="Lead Category"
variant="outlined"
:items="InterestLeadCategoryFlow"
></v-select>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="interest['Time LOI Sent']"
label="Time LOI Sent"
variant="outlined"
readonly
disabled
></v-text-field>
</v-col>
</v-row>
<v-row>
<v-col cols="12" class="d-flex">
<v-btn @click="requestMoreInfoToSales" class="mr-2"
>Request More Info - To Sales</v-btn
>
<v-btn @click="requestMoreInformation" class="mr-2"
>Request More Information</v-btn
>
<v-btn @click="eoiSendToSales">EOI Send to Sales</v-btn>
<v-spacer />
<v-btn type="submit" color="primary" class="mr-2">Save</v-btn>
</v-col>
</v-row>
</v-form>
</v-card-text>
</v-card>
</v-container>
</div>
</template>
<script lang="ts" setup>
import { useRoute, useRouter } from "#app";
import { ref, computed, onMounted } from "vue";
import {
InterestSalesProcessLevelFlow,
InterestLeadCategoryFlow,
ContactMethodPreferredFlow,
} from "@/utils/types";
const router = useRouter();
const route = useRoute();
const { data: interest } = useFetch<Interest>("/api/get-interest-by-id", {
params: {
id: route.params.id,
},
});
const currentStep = computed(() => {
return InterestSalesProcessLevelFlow.indexOf(
interest.value?.["Sales Process Level"] || ""
);
});
const fetchInterest = async () => {
try {
} catch (error) {
console.error("Failed to fetch interest:", error);
}
};
const saveInterest = async () => {
if (interest.value) {
try {
alert("Interest saved successfully!");
} catch (error) {
console.error("Failed to save interest:", error);
}
}
};
const requestMoreInfoToSales = () => {
alert("Request More Info - To Sales");
};
const requestMoreInformation = () => {
alert("Request More Information");
};
const eoiSendToSales = () => {
alert("EOI Send to Sales");
};
</script>

View File

@ -1,124 +0,0 @@
<template>
<div>
<v-container>
<v-text-field
v-model="search"
label="Search contacts"
prepend-inner-icon="mdi-magnify"
variant="outlined"
class="mb-4"
clearable
></v-text-field>
<v-data-table
:headers="headers"
:items="filteredInterests"
:search="search"
:sort-by="[{ key: 'Full Name', order: 'asc' }]"
must-sort
hover
:loading="loading"
loading-text="Loading... Please wait"
:items-per-page="50"
:items-per-page-options="[10, 20, 50, 100]"
>
<template #item="{ item }">
<tr @click="handleRowClick(item)" class="cursor-pointer">
<td>{{ item["Full Name"] }}</td>
<td>{{ item["Yacht Name"] }}</td>
<td>{{ item.Length }}</td>
<td>{{ item.Width }}</td>
<td>{{ item.Depth }}</td>
<td>{{ item["Berth Number"] }}</td>
<td>
<InterestSalesBadge
:salesProcessLevel="item['Sales Process Level']"
/>
</td>
<td>{{ item["Phone Number"] }}</td>
<td>{{ item["Email Address"] }}</td>
<td><LeadCategoryBadge :leadCategory="item['Lead Category']" /></td>
<td>
<v-tooltip bottom>
<template #activator="{ props }">
<v-icon v-if="item['Extra Comments']" v-bind="props"
>mdi-comment</v-icon
>
</template>
<span>{{ item["Extra Comments"] }}</span>
</v-tooltip>
</td>
</tr>
</template>
</v-data-table>
</v-container>
</div>
</template>
<script lang="ts" setup>
import LeadCategoryBadge from "~/components/LeadCategoryBadge.vue";
import InterestSalesBadge from "~/components/InterestSalesBadge.vue";
import { useFetch } from "#app";
import { ref, computed } from "vue";
useHead({
title: "Interest List",
});
const user = useDirectusUser();
const router = useRouter();
const loading = ref(true);
const { data: interests } = useFetch<InterestsResponse>("/api/get-interests", {
headers: {
"x-tag": user.value?.email ? "094ut234" : "pjnvü1230",
},
onResponse() {
loading.value = false;
},
onResponseError() {
loading.value = false;
},
});
const search = ref("");
const handleRowClick = (interest: Interest) => {
router.push(`/dashboard/interest-list/${interest.Id}`);
};
const headers = [
{ title: "Full Name", key: "Full Name", sortable: true },
{ title: "Yacht Name", key: "Yacht Name", sortable: true },
{ title: "Length", key: "Length", sortable: true },
{ title: "Width", key: "Width", sortable: true },
{ title: "Depth", key: "Depth", sortable: true },
{ title: "Berth Number", key: "Berth Number", sortable: true },
{ title: "Sales Process Level", key: "Sales Process Level", sortable: true },
{ title: "Phone", key: "Phone Number", sortable: false },
{ title: "Email", key: "Email Address", sortable: false },
{ title: "Lead Category", key: "Lead Category", sortable: true },
{ title: "Comments", key: "Extra Comments", sortable: false }, // New column for comments
];
const filteredInterests = computed(() => {
if (!interests.value?.list) return [];
return interests.value.list.filter((item) => {
const searchLower = search.value?.toLowerCase() || "";
return Object.values(item).some((value) =>
String(value).toLowerCase().includes(searchLower)
);
});
});
</script>
<style scoped>
tr:hover {
background-color: #f5f5f5;
}
.cursor-pointer {
cursor: pointer;
}
</style>

View File

@ -1,85 +1,181 @@
<template>
<v-card>
<v-card-text>
<v-row class="overflow-x-auto flex-nowrap">
<v-container fluid class="pa-4">
<!-- Header -->
<v-row class="mb-4">
<v-col cols="12">
<h1 class="text-h4 font-weight-bold mb-2">
<v-icon class="mr-2" color="primary">mdi-view-column</v-icon>
Interest Status Board
</h1>
<p class="text-subtitle-1 text-grey-darken-1">
Track interests through the sales pipeline
</p>
</v-col>
</v-row>
<!-- Kanban Board -->
<div class="kanban-container">
<v-row class="flex-nowrap overflow-x-auto" style="min-height: calc(100vh - 200px);">
<v-col
v-for="level in InterestSalesProcessLevelFlow"
v-for="(level, index) in InterestSalesProcessLevelFlow"
:key="level"
class="v-col-auto"
cols="auto"
class="kanban-column"
v-show="groupedInterests[level].length > 0"
>
<v-card
class="h-100 d-flex flex-column column-card"
:style="{ minWidth: '340px', maxWidth: '340px' }"
elevation="0"
>
<!-- Column Header -->
<div class="column-header pa-3">
<v-chip
:color="getColumnColor(level)"
class="text-white font-weight-medium"
label
>
<v-icon start>{{ getColumnIcon(level) }}</v-icon>
{{ level }}
<v-chip
size="small"
variant="flat"
color="white"
class="ml-2"
style="background: rgba(255,255,255,0.3) !important;"
>
{{ groupedInterests[level].length }}
</v-chip>
</v-chip>
</div>
<!-- Column Content -->
<v-card-text
class="flex-grow-1 overflow-y-auto pa-3 column-content"
@dragover="handleDragOver($event)"
@drop="handleDrop($event, level)"
@dragleave="handleDragLeave($event)"
>
<v-card v-if="groupedInterests[level].length" color="grey-lighten-5">
<v-card-title>
<InterestSalesBadge
:sales-process-level="(level as InterestSalesProcessLevel)"
/>
</v-card-title>
<v-card-text class="overflow-y-auto" style="max-height: 90vh">
<v-card
v-for="interest in groupedInterests[level]"
:key="interest.Id"
class="mx-3 my-6"
variant="flat"
class="mb-3 interest-card"
:class="{ 'dragging': draggedInterest?.Id === interest.Id }"
elevation="1"
draggable="true"
@dragstart="handleDragStart($event, interest)"
@dragend="handleDragEnd($event)"
@click="handleInterestClick(interest.Id)"
style="cursor: pointer"
>
<v-card-title>{{ interest["Full Name"] }}</v-card-title>
<v-card-text>
<v-list bg-color="transparent">
<v-list-item>
<v-list-item-title>Yacht Name</v-list-item-title>
<v-list-item-subtitle>{{
interest["Yacht Name"] || "-"
}}</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<v-list-item-title>Email Address</v-list-item-title>
<v-list-item-subtitle>{{
interest["Email Address"] || "-"
}}</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<v-list-item-title>Berth Number</v-list-item-title>
<v-list-item-subtitle>{{
interest["Berth Number"] || "-"
}}</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<v-list-item-title>Phone Number</v-list-item-title>
<v-list-item-subtitle>{{
interest["Phone Number"] || "-"
}}</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<v-list-item-title>Berth Size Desired</v-list-item-title>
<v-list-item-subtitle>{{
interest["Berth Size Desired"] || "-"
}}</v-list-item-subtitle>
</v-list-item>
</v-list>
<v-card-text class="pa-3">
<!-- Name and Yacht -->
<div class="d-flex align-center mb-2">
<v-avatar size="32" :color="getColumnColor(level)" class="mr-2">
<span class="text-white text-caption font-weight-bold">
{{ getInitials(interest["Full Name"]) }}
</span>
</v-avatar>
<div class="flex-grow-1">
<div class="font-weight-medium">{{ interest["Full Name"] }}</div>
<div class="text-caption text-grey-darken-1">{{ interest["Yacht Name"] || "No yacht specified" }}</div>
</div>
</div>
<!-- Contact Info -->
<div class="mb-2">
<div class="d-flex align-center mb-1" v-if="interest['Email Address']">
<v-icon size="small" class="mr-2" color="grey-darken-2">mdi-email</v-icon>
<span class="text-caption text-truncate">{{ interest["Email Address"] }}</span>
</div>
<div class="d-flex align-center mb-1" v-if="interest['Phone Number']">
<v-icon size="small" class="mr-2" color="grey-darken-2">mdi-phone</v-icon>
<span class="text-caption">{{ interest["Phone Number"] }}</span>
</div>
</div>
<!-- Berth Info -->
<div class="d-flex flex-wrap ga-1">
<v-chip
v-if="interest['Berth Number']"
size="x-small"
color="deep-purple"
variant="tonal"
>
<v-icon start size="x-small">mdi-anchor</v-icon>
{{ interest["Berth Number"] }}
</v-chip>
<v-chip
v-if="interest['Berth Size Desired']"
size="x-small"
color="blue"
variant="tonal"
>
<v-icon start size="x-small">mdi-ruler</v-icon>
{{ interest["Berth Size Desired"] }}
</v-chip>
</div>
<!-- Extra Comments -->
<div v-if="interest['Extra Comments']" class="mt-2">
<v-chip size="x-small" color="orange" variant="tonal">
<v-icon start size="x-small">mdi-comment</v-icon>
Has notes
</v-chip>
</div>
</v-card-text>
</v-card>
</v-card-text>
</v-card>
</v-col>
<!-- Empty state message if no columns are visible -->
<v-col
v-if="visibleColumnsCount === 0"
cols="12"
class="text-center py-8"
>
<v-icon size="64" color="grey-lighten-1">mdi-view-column-outline</v-icon>
<h3 class="text-h5 mt-4 mb-2">No interests to display</h3>
<p class="text-grey-darken-1">Interests will appear here as they progress through the sales pipeline</p>
</v-col>
</v-row>
</v-card-text>
</v-card>
</div>
</v-container>
<!-- Interest Details Modal -->
<InterestDetailsModal
v-model="showModal"
:selected-interest="selectedInterest"
@save="handleSaveInterest"
@request-more-info-to-sales="handleRequestMoreInfoToSales"
@request-more-information="handleRequestMoreInformation"
@eoi-send-to-sales="handleEoiSendToSales"
/>
</template>
<script lang="ts" setup>
import InterestDetailsModal from "~/components/InterestDetailsModal.vue";
import InterestSalesBadge from "~/components/InterestSalesBadge.vue";
import { useFetch } from "#app";
import { ref, computed } from "vue";
import { useRouter } from "#app";
const router = useRouter();
import type { Interest, InterestSalesProcessLevel, InterestsResponse } from "@/utils/types";
import { InterestSalesProcessLevelFlow } from "@/utils/types";
useHead({
title: "Interest Status",
});
const user = useDirectusUser();
const loading = ref(true);
const showModal = ref(false);
const selectedInterest = ref<Interest | null>(null);
const draggedInterest = ref<Interest | null>(null);
const draggedFromLevel = ref<string | null>(null);
const { data: interests } = useFetch<InterestsResponse>("/api/get-interests", {
headers: {
"x-tag": user.value?.email ? "094ut234" : "pjnvü1230",
},
onResponse() {
loading.value = false;
},
@ -102,41 +198,295 @@ const groupedInterests = computed(() => {
}
});
// Sort each group by "Full Name"
// Sort each group by "Created At" (newest first)
for (const level in groups) {
groups[level].sort((a, b) => {
if (a["Full Name"] < b["Full Name"]) return -1;
if (a["Full Name"] > b["Full Name"]) return 1;
return 0;
// Convert date strings to comparable format
const dateA = parseDate(a["Created At"]);
const dateB = parseDate(b["Created At"]);
return dateB.getTime() - dateA.getTime();
});
}
return groups;
});
const handleInterestClick = (id: number) => {
router.push(`/dashboard/interest-list/${id}`);
// Count visible columns
const visibleColumnsCount = computed(() => {
return InterestSalesProcessLevelFlow.filter(level => groupedInterests.value[level].length > 0).length;
});
// Helper function to parse date
const parseDate = (dateString: string) => {
if (!dateString) return new Date(0);
// Handle DD-MM-YYYY format
if (dateString.includes('-')) {
const parts = dateString.split('-');
if (parts.length === 3) {
const [day, month, year] = parts;
return new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
}
}
return new Date(dateString);
};
// Helper function to get initials
const getInitials = (name: string) => {
if (!name) return '?';
const parts = name.split(' ');
if (parts.length >= 2) {
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
return name.substring(0, 2).toUpperCase();
};
// Get column color based on sales process level
const getColumnColor = (level: string) => {
const colorMap: Record<string, string> = {
'General Qualified Interest': 'grey-darken-1',
'Specific Qualified Interest': 'grey-darken-2',
'LOI and NDA Sent': 'light-blue',
'Signed LOI and NDA': 'blue',
'Made Reservation': 'green',
'Contract Negotiation': 'orange',
'Contract Negotiations Finalized': 'deep-orange',
'Contract Signed': 'green-darken-2'
};
return colorMap[level] || 'grey';
};
// Get column icon based on sales process level
const getColumnIcon = (level: string) => {
const iconMap: Record<string, string> = {
'General Qualified Interest': 'mdi-account-search',
'Specific Qualified Interest': 'mdi-account-check',
'LOI and NDA Sent': 'mdi-email-send',
'Signed LOI and NDA': 'mdi-file-sign',
'Made Reservation': 'mdi-calendar-check',
'Contract Negotiation': 'mdi-handshake',
'Contract Negotiations Finalized': 'mdi-file-document-check',
'Contract Signed': 'mdi-check-circle'
};
return iconMap[level] || 'mdi-circle';
};
const handleInterestClick = (interestId: number) => {
// Find the interest by ID
const interest = interests.value?.list.find(i => i.Id === interestId);
if (interest) {
selectedInterest.value = { ...interest };
showModal.value = true;
}
};
// Event handlers for the modal
const handleSaveInterest = (interest: Interest) => {
// Update the interest in the local list if needed
// The modal component already handles the API call and shows success/error messages
// You can add additional logic here if needed
};
const handleRequestMoreInfoToSales = (interest: Interest) => {
// The modal component already handles the API call
// You can add additional logic here if needed
};
const handleRequestMoreInformation = (interest: Interest) => {
// The modal component already handles the API call
// You can add additional logic here if needed
};
const handleEoiSendToSales = (interest: Interest) => {
// The modal component already handles the API call
// You can add additional logic here if needed
};
// Drag and Drop handlers
const handleDragStart = (event: DragEvent, interest: Interest) => {
draggedInterest.value = interest;
draggedFromLevel.value = interest["Sales Process Level"];
// Add visual feedback
if (event.target instanceof HTMLElement) {
event.target.style.opacity = '0.5';
}
// Store interest data in dataTransfer
event.dataTransfer!.effectAllowed = 'move';
event.dataTransfer!.setData('text/plain', JSON.stringify(interest));
};
const handleDragEnd = (event: DragEvent) => {
// Remove visual feedback
if (event.target instanceof HTMLElement) {
event.target.style.opacity = '';
}
draggedInterest.value = null;
draggedFromLevel.value = null;
};
const handleDragOver = (event: DragEvent) => {
event.preventDefault();
event.dataTransfer!.dropEffect = 'move';
// Add visual feedback to drop zone
if (event.currentTarget instanceof HTMLElement) {
event.currentTarget.classList.add('drag-over');
}
};
const handleDragLeave = (event: DragEvent) => {
// Remove visual feedback from drop zone
if (event.currentTarget instanceof HTMLElement) {
event.currentTarget.classList.remove('drag-over');
}
};
const handleDrop = async (event: DragEvent, targetLevel: string) => {
event.preventDefault();
// Remove visual feedback
if (event.currentTarget instanceof HTMLElement) {
event.currentTarget.classList.remove('drag-over');
}
if (!draggedInterest.value || draggedFromLevel.value === targetLevel) {
return;
}
try {
// Update the interest's sales process level
const response = await $fetch('/api/update-interest', {
method: 'POST',
headers: {
'x-tag': '094ut234'
},
body: {
id: draggedInterest.value.Id,
data: {
'Sales Process Level': targetLevel
}
}
});
// Update local data
const interestIndex = interests.value?.list.findIndex(i => i.Id === draggedInterest.value!.Id);
if (interestIndex !== undefined && interestIndex !== -1 && interests.value) {
interests.value.list[interestIndex]['Sales Process Level'] = targetLevel;
}
// Show success message
const { $toast } = useNuxtApp();
$toast.success(`Moved to ${targetLevel}`);
} catch (error) {
console.error('Failed to update interest:', error);
const { $toast } = useNuxtApp();
$toast.error('Failed to update interest status');
}
};
</script>
<style scoped>
.v-card {
border-radius: 8px;
.kanban-container {
background-color: #f5f5f5;
border-radius: 12px;
padding: 20px;
}
.v-card-title {
font-weight: bold;
.kanban-column {
padding: 0 10px;
}
.v-list-item {
padding: 0;
.column-card {
background: #fafafa;
border: 1px solid #e0e0e0;
}
.v-list-item-content {
padding: 0;
.column-header {
background: white;
border-bottom: 1px solid #e0e0e0;
}
.v-list-item-subtitle {
white-space: normal;
.column-content {
background: transparent;
position: relative;
}
.column-content.drag-over {
background: rgba(33, 150, 243, 0.1);
border: 2px dashed #2196F3;
border-radius: 4px;
}
.interest-card {
cursor: move;
transition: all 0.2s ease;
background: white;
border: 1px solid #e0e0e0;
}
.interest-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1) !important;
border-color: #d0d0d0;
}
.interest-card.dragging {
opacity: 0.5;
cursor: grabbing;
}
.text-truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 240px;
}
/* Custom scrollbar for column content */
.v-card-text::-webkit-scrollbar {
width: 6px;
}
.v-card-text::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.05);
border-radius: 3px;
}
.v-card-text::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 3px;
}
.v-card-text::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.3);
}
/* Ensure horizontal scroll for the board */
.overflow-x-auto {
overflow-x: auto;
overflow-y: hidden;
}
.overflow-x-auto::-webkit-scrollbar {
height: 10px;
}
.overflow-x-auto::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 5px;
}
.overflow-x-auto::-webkit-scrollbar-thumb {
background: #888;
border-radius: 5px;
}
.overflow-x-auto::-webkit-scrollbar-thumb:hover {
background: #555;
}
</style>

View File

@ -0,0 +1,50 @@
export default defineEventHandler(async (event) => {
const xTagHeader = getRequestHeader(event, "x-tag");
if (!xTagHeader || xTagHeader !== "094ut234") {
throw createError({ statusCode: 401, statusMessage: "unauthenticated" });
}
try {
const body = await readBody(event);
const { interestId } = body;
if (!interestId) {
throw createError({ statusCode: 400, statusMessage: "Interest ID is required" });
}
// Get the interest data
const interest = await getInterestById(interestId);
// Prepare the webhook payload
const webhookPayload = {
"type": "records.after.trigger",
"id": crypto.randomUUID(), // Generate a random UUID
"data": {
"table_id": "mbs9hjauug4eseo",
"table_name": "Interests",
"rows": [interest]
}
};
// Trigger the webhook
const webhookUrl = "https://automation.portnimara.com/api/v1/webhooks/cCRKsqPB9AHuj4XjFFiPr";
await triggerWebhook(webhookUrl, webhookPayload);
// Update the interest to mark that the EOI was sent
await updateInterest(interestId, {
"EOI Send to Sales": new Date().toISOString()
});
return { success: true, message: "EOI send to sales triggered successfully" };
} catch (error) {
if (error instanceof Error) {
throw createError({ statusCode: 500, statusMessage: error.message });
} else {
throw createError({
statusCode: 500,
statusMessage: "An unexpected error occurred",
});
}
}
});

21
server/api/get-berths.ts Normal file
View File

@ -0,0 +1,21 @@
export default defineEventHandler(async (event) => {
const xTagHeader = getRequestHeader(event, "x-tag");
if (!xTagHeader || xTagHeader !== "094ut234") {
throw createError({ statusCode: 401, statusMessage: "unauthenticated" });
}
const config = getNocoDbConfiguration();
const berthsTableId = "mczgos9hr3oa9qc";
const berths = await $fetch(`${config.url}/api/v2/tables/${berthsTableId}/records`, {
headers: {
"xc-token": config.token,
},
params: {
limit: 1000,
},
});
return berths;
});

View File

@ -0,0 +1,47 @@
export default defineEventHandler(async (event) => {
const xTagHeader = getRequestHeader(event, "x-tag");
if (!xTagHeader || xTagHeader !== "094ut234") {
throw createError({ statusCode: 401, statusMessage: "unauthenticated" });
}
const query = getQuery(event);
const { interestId, linkType } = query;
if (!interestId || !linkType) {
throw createError({
statusCode: 400,
statusMessage: "interestId and linkType are required"
});
}
const config = getNocoDbConfiguration();
const interestsTableId = "mbs9hjauug4eseo";
// Determine which link field to use
let linkFieldId;
if (linkType === 'berths') {
linkFieldId = "cj7v7bb9pa5eyo3"; // Berths field
} else if (linkType === 'recommendations') {
linkFieldId = "cgthyq2e95ajc52"; // Berth Recommendations field
} else {
throw createError({
statusCode: 400,
statusMessage: "linkType must be 'berths' or 'recommendations'"
});
}
const result = await $fetch(
`${config.url}/api/v2/tables/${interestsTableId}/links/${linkFieldId}/records/${interestId}`,
{
headers: {
"xc-token": config.token,
},
params: {
limit: 1000,
},
}
);
return result;
});

View File

@ -0,0 +1,37 @@
export default defineEventHandler(async (event) => {
const xTagHeader = getRequestHeader(event, "x-tag");
if (!xTagHeader || xTagHeader !== "094ut234") {
throw createError({ statusCode: 401, statusMessage: "unauthenticated" });
}
const body = await readBody(event);
const { interestId, berthIds } = body;
if (!interestId || !berthIds || !Array.isArray(berthIds)) {
throw createError({
statusCode: 400,
statusMessage: "interestId and berthIds array are required"
});
}
const config = getNocoDbConfiguration();
const interestsTableId = "mbs9hjauug4eseo";
const berthRecommendationsLinkFieldId = "cgthyq2e95ajc52"; // Berth Recommendations field
// Format the berth IDs for the API
const berthRecords = berthIds.map(id => ({ Id: id }));
const result = await $fetch(
`${config.url}/api/v2/tables/${interestsTableId}/links/${berthRecommendationsLinkFieldId}/records/${interestId}`,
{
method: 'POST',
headers: {
"xc-token": config.token,
},
body: berthRecords,
}
);
return result;
});

View File

@ -0,0 +1,37 @@
export default defineEventHandler(async (event) => {
const xTagHeader = getRequestHeader(event, "x-tag");
if (!xTagHeader || xTagHeader !== "094ut234") {
throw createError({ statusCode: 401, statusMessage: "unauthenticated" });
}
const body = await readBody(event);
const { interestId, berthIds } = body;
if (!interestId || !berthIds || !Array.isArray(berthIds)) {
throw createError({
statusCode: 400,
statusMessage: "interestId and berthIds array are required"
});
}
const config = getNocoDbConfiguration();
const interestsTableId = "mbs9hjauug4eseo";
const berthsLinkFieldId = "cj7v7bb9pa5eyo3"; // Berths field
// Format the berth IDs for the API
const berthRecords = berthIds.map(id => ({ Id: id }));
const result = await $fetch(
`${config.url}/api/v2/tables/${interestsTableId}/links/${berthsLinkFieldId}/records/${interestId}`,
{
method: 'POST',
headers: {
"xc-token": config.token,
},
body: berthRecords,
}
);
return result;
});

View File

@ -0,0 +1,50 @@
export default defineEventHandler(async (event) => {
const xTagHeader = getRequestHeader(event, "x-tag");
if (!xTagHeader || xTagHeader !== "094ut234") {
throw createError({ statusCode: 401, statusMessage: "unauthenticated" });
}
try {
const body = await readBody(event);
const { interestId } = body;
if (!interestId) {
throw createError({ statusCode: 400, statusMessage: "Interest ID is required" });
}
// Get the interest data
const interest = await getInterestById(interestId);
// Prepare the webhook payload
const webhookPayload = {
"type": "records.after.trigger",
"id": crypto.randomUUID(), // Generate a random UUID
"data": {
"table_id": "mbs9hjauug4eseo",
"table_name": "Interests",
"rows": [interest]
}
};
// Trigger the webhook
const webhookUrl = "https://automation.portnimara.com/api/v1/webhooks/vEWQdpe4CXS24E86tV2Cb";
await triggerWebhook(webhookUrl, webhookPayload);
// Update the interest to mark that the request was sent
await updateInterest(interestId, {
"Request More Info - To Sales": new Date().toISOString()
});
return { success: true, message: "Request more info to sales triggered successfully" };
} catch (error) {
if (error instanceof Error) {
throw createError({ statusCode: 500, statusMessage: error.message });
} else {
throw createError({
statusCode: 500,
statusMessage: "An unexpected error occurred",
});
}
}
});

View File

@ -0,0 +1,50 @@
export default defineEventHandler(async (event) => {
const xTagHeader = getRequestHeader(event, "x-tag");
if (!xTagHeader || xTagHeader !== "094ut234") {
throw createError({ statusCode: 401, statusMessage: "unauthenticated" });
}
try {
const body = await readBody(event);
const { interestId } = body;
if (!interestId) {
throw createError({ statusCode: 400, statusMessage: "Interest ID is required" });
}
// Get the interest data
const interest = await getInterestById(interestId);
// Prepare the webhook payload
const webhookPayload = {
"type": "records.after.trigger",
"id": crypto.randomUUID(), // Generate a random UUID
"data": {
"table_id": "mbs9hjauug4eseo",
"table_name": "Interests",
"rows": [interest]
}
};
// Trigger the webhook
const webhookUrl = "https://automation.portnimara.com/api/v1/webhooks/B6lnXZoospLXcVJJTABh0";
await triggerWebhook(webhookUrl, webhookPayload);
// Update the interest to mark that the request was sent
await updateInterest(interestId, {
"Request More Information": new Date().toISOString()
});
return { success: true, message: "Request more information triggered successfully" };
} catch (error) {
if (error instanceof Error) {
throw createError({ statusCode: 500, statusMessage: error.message });
} else {
throw createError({
statusCode: 500,
statusMessage: "An unexpected error occurred",
});
}
}
});

View File

@ -0,0 +1,37 @@
export default defineEventHandler(async (event) => {
const xTagHeader = getRequestHeader(event, "x-tag");
if (!xTagHeader || xTagHeader !== "094ut234") {
throw createError({ statusCode: 401, statusMessage: "unauthenticated" });
}
const body = await readBody(event);
const { interestId, berthIds } = body;
if (!interestId || !berthIds || !Array.isArray(berthIds)) {
throw createError({
statusCode: 400,
statusMessage: "interestId and berthIds array are required"
});
}
const config = getNocoDbConfiguration();
const interestsTableId = "mbs9hjauug4eseo";
const berthRecommendationsLinkFieldId = "cgthyq2e95ajc52"; // Berth Recommendations field
// Format the berth IDs for the API
const berthRecords = berthIds.map(id => ({ Id: id }));
const result = await $fetch(
`${config.url}/api/v2/tables/${interestsTableId}/links/${berthRecommendationsLinkFieldId}/records/${interestId}`,
{
method: 'DELETE',
headers: {
"xc-token": config.token,
},
body: berthRecords,
}
);
return result;
});

View File

@ -0,0 +1,37 @@
export default defineEventHandler(async (event) => {
const xTagHeader = getRequestHeader(event, "x-tag");
if (!xTagHeader || xTagHeader !== "094ut234") {
throw createError({ statusCode: 401, statusMessage: "unauthenticated" });
}
const body = await readBody(event);
const { interestId, berthIds } = body;
if (!interestId || !berthIds || !Array.isArray(berthIds)) {
throw createError({
statusCode: 400,
statusMessage: "interestId and berthIds array are required"
});
}
const config = getNocoDbConfiguration();
const interestsTableId = "mbs9hjauug4eseo";
const berthsLinkFieldId = "cj7v7bb9pa5eyo3"; // Berths field
// Format the berth IDs for the API
const berthRecords = berthIds.map(id => ({ Id: id }));
const result = await $fetch(
`${config.url}/api/v2/tables/${interestsTableId}/links/${berthsLinkFieldId}/records/${interestId}`,
{
method: 'DELETE',
headers: {
"xc-token": config.token,
},
body: berthRecords,
}
);
return result;
});

View File

@ -0,0 +1,38 @@
export default defineEventHandler(async (event) => {
const xTagHeader = getRequestHeader(event, "x-tag");
if (!xTagHeader || xTagHeader !== "094ut234") {
throw createError({ statusCode: 401, statusMessage: "unauthenticated" });
}
try {
const body = await readBody(event);
const { id, data } = body;
if (!id) {
throw createError({ statusCode: 400, statusMessage: "ID is required" });
}
if (!data || Object.keys(data).length === 0) {
throw createError({ statusCode: 400, statusMessage: "No data provided for update" });
}
// Remove Id from data if it exists to avoid duplication
const updateData = { ...data };
if ('Id' in updateData) {
delete updateData.Id;
}
const updatedInterest = await updateInterest(id, updateData);
return updatedInterest;
} catch (error) {
if (error instanceof Error) {
throw createError({ statusCode: 500, statusMessage: error.message });
} else {
throw createError({
statusCode: 500,
statusMessage: "An unexpected error occurred",
});
}
}
});

View File

@ -36,3 +36,44 @@ export const getInterestById = async (id: string) =>
"xc-token": getNocoDbConfiguration().token,
},
});
export const updateInterest = async (id: string, data: Partial<Interest>) => {
// Create a clean data object that matches the InterestsRequest schema
// Remove any properties that are not in the schema or shouldn't be sent
const cleanData: Record<string, any> = {};
// Only include fields that are part of the InterestsRequest schema
const allowedFields = [
"Full Name", "Yacht Name", "Length", "Address", "Email Address",
"Sales Process Level", "Phone Number", "Extra Comments", "Berth Size Desired",
"LOI-NDA Document", "Date Added", "Width", "Depth", "Created At",
"Request More Information", "Source", "Place of Residence",
"Contact Method Preferred", "Request Form Sent", "Berth Number",
"EOI Time Sent", "Lead Category", "Request More Info - To Sales",
"EOI Send to Sales", "Time LOI Sent"
];
// Filter the data to only include allowed fields
for (const field of allowedFields) {
if (field in data) {
cleanData[field] = data[field];
}
}
return $fetch<Interest>(createTableUrl(Table.Interest), {
method: 'PATCH',
headers: {
"xc-token": getNocoDbConfiguration().token,
},
body: {
Id: id, // This identifies the record to update
...cleanData // These are the fields to update
},
});
};
export const triggerWebhook = async (url: string, payload: any) =>
$fetch(url, {
method: 'POST',
body: payload,
});

View File

@ -1,4 +1,5 @@
export interface Berth {
Id: number;
"Mooring Number": string;
Length: string;
Draft: string;
@ -92,4 +93,9 @@ export interface Interest {
"Request More Info - To Sales": string;
"EOI Send to Sales": string;
"Time LOI Sent": string;
Berths: number;
}
export interface InterestsResponse {
list: Interest[];
}