493 lines
15 KiB
Vue
493 lines
15 KiB
Vue
<template>
|
|
<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, index) in InterestSalesProcessLevelFlow"
|
|
:key="level"
|
|
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-for="interest in groupedInterests[level]"
|
|
:key="interest.Id"
|
|
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)"
|
|
>
|
|
<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>
|
|
</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 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;
|
|
},
|
|
onResponseError() {
|
|
loading.value = false;
|
|
},
|
|
});
|
|
|
|
const groupedInterests = computed(() => {
|
|
const groups: Record<string, Interest[]> = {};
|
|
|
|
InterestSalesProcessLevelFlow.forEach((level) => {
|
|
groups[level] = [];
|
|
});
|
|
|
|
interests.value?.list.forEach((interest) => {
|
|
const level = interest["Sales Process Level"];
|
|
if (groups[level]) {
|
|
groups[level].push(interest);
|
|
}
|
|
});
|
|
|
|
// Sort each group by "Created At" (newest first)
|
|
for (const level in groups) {
|
|
groups[level].sort((a, b) => {
|
|
// 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;
|
|
});
|
|
|
|
// 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>
|
|
.kanban-container {
|
|
background-color: #f5f5f5;
|
|
border-radius: 12px;
|
|
padding: 20px;
|
|
}
|
|
|
|
.kanban-column {
|
|
padding: 0 10px;
|
|
}
|
|
|
|
.column-card {
|
|
background: #fafafa;
|
|
border: 1px solid #e0e0e0;
|
|
}
|
|
|
|
.column-header {
|
|
background: white;
|
|
border-bottom: 1px solid #e0e0e0;
|
|
}
|
|
|
|
.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>
|