feat: add interest button
This commit is contained in:
parent
0437ba6462
commit
bc0fa6fbe0
1
app.vue
1
app.vue
|
|
@ -1,4 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<NuxtPwaManifest />
|
<NuxtPwaManifest />
|
||||||
<NuxtPage />
|
<NuxtPage />
|
||||||
|
<GlobalToast />
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,347 @@
|
||||||
|
<template>
|
||||||
|
<v-dialog
|
||||||
|
v-model="isOpen"
|
||||||
|
max-width="800"
|
||||||
|
persistent
|
||||||
|
>
|
||||||
|
<v-card>
|
||||||
|
<v-toolbar dark color="primary">
|
||||||
|
<v-toolbar-title>
|
||||||
|
<v-icon class="mr-2">mdi-account-plus</v-icon>
|
||||||
|
Create New Interest
|
||||||
|
</v-toolbar-title>
|
||||||
|
<v-spacer></v-spacer>
|
||||||
|
<v-btn icon dark @click="closeModal">
|
||||||
|
<v-icon>mdi-close</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</v-toolbar>
|
||||||
|
|
||||||
|
<v-card-text class="pa-4">
|
||||||
|
<v-alert
|
||||||
|
type="info"
|
||||||
|
variant="tonal"
|
||||||
|
class="mb-4"
|
||||||
|
>
|
||||||
|
Berths and berth recommendations can only be assigned after creating the interest.
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
|
<v-form ref="form" @submit.prevent="createInterest">
|
||||||
|
<!-- Contact Information Section -->
|
||||||
|
<div class="text-h6 mb-3">
|
||||||
|
<v-icon class="mr-2" color="primary">mdi-account-circle</v-icon>
|
||||||
|
Contact Information
|
||||||
|
</div>
|
||||||
|
<v-row dense class="mb-4">
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-text-field
|
||||||
|
v-model="newInterest['Full Name']"
|
||||||
|
label="Full Name *"
|
||||||
|
variant="outlined"
|
||||||
|
density="comfortable"
|
||||||
|
prepend-inner-icon="mdi-account"
|
||||||
|
:rules="[rules.required]"
|
||||||
|
required
|
||||||
|
></v-text-field>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-text-field
|
||||||
|
v-model="newInterest['Email Address']"
|
||||||
|
label="Email Address *"
|
||||||
|
variant="outlined"
|
||||||
|
density="comfortable"
|
||||||
|
prepend-inner-icon="mdi-email"
|
||||||
|
:rules="[rules.required, rules.email]"
|
||||||
|
required
|
||||||
|
></v-text-field>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-text-field
|
||||||
|
v-model="newInterest['Phone Number']"
|
||||||
|
label="Phone Number"
|
||||||
|
variant="outlined"
|
||||||
|
density="comfortable"
|
||||||
|
prepend-inner-icon="mdi-phone"
|
||||||
|
></v-text-field>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-select
|
||||||
|
v-model="newInterest['Contact Method Preferred']"
|
||||||
|
label="Preferred Contact Method"
|
||||||
|
variant="outlined"
|
||||||
|
density="comfortable"
|
||||||
|
:items="ContactMethodPreferredFlow"
|
||||||
|
prepend-inner-icon="mdi-message-text"
|
||||||
|
></v-select>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-text-field
|
||||||
|
v-model="newInterest.Address"
|
||||||
|
label="Address"
|
||||||
|
variant="outlined"
|
||||||
|
density="comfortable"
|
||||||
|
prepend-inner-icon="mdi-map-marker"
|
||||||
|
></v-text-field>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-text-field
|
||||||
|
v-model="newInterest['Place of Residence']"
|
||||||
|
label="Place of Residence"
|
||||||
|
variant="outlined"
|
||||||
|
density="comfortable"
|
||||||
|
prepend-inner-icon="mdi-home"
|
||||||
|
></v-text-field>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<!-- Yacht Information Section -->
|
||||||
|
<div class="text-h6 mb-3">
|
||||||
|
<v-icon class="mr-2" color="primary">mdi-sail-boat</v-icon>
|
||||||
|
Yacht Information
|
||||||
|
</div>
|
||||||
|
<v-row dense class="mb-4">
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-text-field
|
||||||
|
v-model="newInterest['Yacht Name']"
|
||||||
|
label="Yacht Name"
|
||||||
|
variant="outlined"
|
||||||
|
density="comfortable"
|
||||||
|
prepend-inner-icon="mdi-ferry"
|
||||||
|
></v-text-field>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-text-field
|
||||||
|
v-model="newInterest['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-text-field
|
||||||
|
v-model="newInterest.Length"
|
||||||
|
label="Length"
|
||||||
|
variant="outlined"
|
||||||
|
density="comfortable"
|
||||||
|
prepend-inner-icon="mdi-ruler"
|
||||||
|
></v-text-field>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="4">
|
||||||
|
<v-text-field
|
||||||
|
v-model="newInterest.Width"
|
||||||
|
label="Width"
|
||||||
|
variant="outlined"
|
||||||
|
density="comfortable"
|
||||||
|
prepend-inner-icon="mdi-arrow-expand-horizontal"
|
||||||
|
></v-text-field>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="4">
|
||||||
|
<v-text-field
|
||||||
|
v-model="newInterest.Depth"
|
||||||
|
label="Depth"
|
||||||
|
variant="outlined"
|
||||||
|
density="comfortable"
|
||||||
|
prepend-inner-icon="mdi-arrow-expand-vertical"
|
||||||
|
></v-text-field>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<!-- Sales Information Section -->
|
||||||
|
<div class="text-h6 mb-3">
|
||||||
|
<v-icon class="mr-2" color="primary">mdi-chart-line</v-icon>
|
||||||
|
Sales Information
|
||||||
|
</div>
|
||||||
|
<v-row dense class="mb-4">
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-select
|
||||||
|
v-model="newInterest['Sales Process Level']"
|
||||||
|
label="Sales Process Level *"
|
||||||
|
variant="outlined"
|
||||||
|
density="comfortable"
|
||||||
|
:items="InterestSalesProcessLevelFlow"
|
||||||
|
prepend-inner-icon="mdi-chart-line"
|
||||||
|
:rules="[rules.required]"
|
||||||
|
required
|
||||||
|
></v-select>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-select
|
||||||
|
v-model="newInterest['Lead Category']"
|
||||||
|
label="Lead Category"
|
||||||
|
variant="outlined"
|
||||||
|
density="comfortable"
|
||||||
|
:items="InterestLeadCategoryFlow"
|
||||||
|
prepend-inner-icon="mdi-tag"
|
||||||
|
></v-select>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
|
||||||
|
<!-- Additional Information Section -->
|
||||||
|
<div class="text-h6 mb-3">
|
||||||
|
<v-icon class="mr-2" color="primary">mdi-information</v-icon>
|
||||||
|
Additional Information
|
||||||
|
</div>
|
||||||
|
<v-row dense>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-text-field
|
||||||
|
v-model="newInterest.Source"
|
||||||
|
label="Source"
|
||||||
|
variant="outlined"
|
||||||
|
density="comfortable"
|
||||||
|
prepend-inner-icon="mdi-source-branch"
|
||||||
|
></v-text-field>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-text-field
|
||||||
|
v-model="newInterest['Extra Comments']"
|
||||||
|
label="Extra Comments"
|
||||||
|
variant="outlined"
|
||||||
|
density="comfortable"
|
||||||
|
prepend-inner-icon="mdi-comment-text"
|
||||||
|
></v-text-field>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-form>
|
||||||
|
</v-card-text>
|
||||||
|
|
||||||
|
<v-card-actions class="pa-4">
|
||||||
|
<v-spacer></v-spacer>
|
||||||
|
<v-btn
|
||||||
|
variant="text"
|
||||||
|
@click="closeModal"
|
||||||
|
:disabled="isCreating"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
@click="createInterest"
|
||||||
|
:loading="isCreating"
|
||||||
|
:disabled="isCreating"
|
||||||
|
>
|
||||||
|
<v-icon start>mdi-content-save</v-icon>
|
||||||
|
Create Interest
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, computed, watch } from "vue";
|
||||||
|
import type { Interest } from "@/utils/types";
|
||||||
|
import {
|
||||||
|
InterestSalesProcessLevelFlow,
|
||||||
|
InterestLeadCategoryFlow,
|
||||||
|
ContactMethodPreferredFlow,
|
||||||
|
} from "@/utils/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
modelValue: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: "update:modelValue", value: boolean): void;
|
||||||
|
(e: "created", interest: Interest): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
const emit = defineEmits<Emits>();
|
||||||
|
|
||||||
|
const user = useDirectusUser();
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
// Form ref
|
||||||
|
const form = ref();
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
const isCreating = ref(false);
|
||||||
|
|
||||||
|
// Initial interest data
|
||||||
|
const getInitialInterest = () => ({
|
||||||
|
"Full Name": "",
|
||||||
|
"Email Address": "",
|
||||||
|
"Phone Number": "",
|
||||||
|
"Contact Method Preferred": "Email",
|
||||||
|
Address: "",
|
||||||
|
"Place of Residence": "",
|
||||||
|
"Yacht Name": "",
|
||||||
|
"Berth Size Desired": "",
|
||||||
|
Length: "",
|
||||||
|
Width: "",
|
||||||
|
Depth: "",
|
||||||
|
"Sales Process Level": "General Qualified Interest",
|
||||||
|
"Lead Category": "General",
|
||||||
|
Source: "",
|
||||||
|
"Extra Comments": "",
|
||||||
|
"Date Added": new Date().toLocaleDateString("en-GB").replace(/\//g, "-"),
|
||||||
|
"Created At": new Date().toLocaleDateString("en-GB").replace(/\//g, "-"),
|
||||||
|
});
|
||||||
|
|
||||||
|
// New interest data
|
||||||
|
const newInterest = ref(getInitialInterest());
|
||||||
|
|
||||||
|
// Validation rules
|
||||||
|
const rules = {
|
||||||
|
required: (v: any) => !!v || "This field is required",
|
||||||
|
email: (v: string) => {
|
||||||
|
const pattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
return pattern.test(v) || "Invalid email address";
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Computed property for v-model binding
|
||||||
|
const isOpen = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (value: boolean) => emit("update:modelValue", value),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset form when dialog opens
|
||||||
|
watch(isOpen, (newValue) => {
|
||||||
|
if (newValue) {
|
||||||
|
newInterest.value = getInitialInterest();
|
||||||
|
form.value?.resetValidation();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
isOpen.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createInterest = async () => {
|
||||||
|
// Validate form
|
||||||
|
const { valid } = await form.value.validate();
|
||||||
|
if (!valid) {
|
||||||
|
toast.error("Please fill in all required fields");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isCreating.value = true;
|
||||||
|
try {
|
||||||
|
// Call the create-interest API
|
||||||
|
const response = await $fetch<Interest>("/api/create-interest", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"x-tag": user.value?.email ? "094ut234" : "pjnvü1230",
|
||||||
|
},
|
||||||
|
body: newInterest.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success("Interest created successfully!");
|
||||||
|
emit("created", response);
|
||||||
|
closeModal();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to create interest:", error);
|
||||||
|
toast.error("Failed to create interest. Please try again.");
|
||||||
|
} finally {
|
||||||
|
isCreating.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.v-alert {
|
||||||
|
border-left: 4px solid rgb(var(--v-theme-info));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
<template>
|
||||||
|
<v-snackbar
|
||||||
|
v-model="toast.show"
|
||||||
|
:color="toast.color"
|
||||||
|
:timeout="toast.timeout"
|
||||||
|
location="top"
|
||||||
|
variant="flat"
|
||||||
|
>
|
||||||
|
<div class="d-flex align-center">
|
||||||
|
<v-icon v-if="toast.color === 'success'" class="mr-2">mdi-check-circle</v-icon>
|
||||||
|
<v-icon v-else-if="toast.color === 'error'" class="mr-2">mdi-alert-circle</v-icon>
|
||||||
|
<v-icon v-else-if="toast.color === 'warning'" class="mr-2">mdi-alert</v-icon>
|
||||||
|
<v-icon v-else-if="toast.color === 'info'" class="mr-2">mdi-information</v-icon>
|
||||||
|
<span>{{ toast.message }}</span>
|
||||||
|
</div>
|
||||||
|
<template v-slot:actions>
|
||||||
|
<v-btn
|
||||||
|
variant="text"
|
||||||
|
@click="toast.show = false"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
</v-snackbar>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useToast } from '~/composables/useToast'
|
||||||
|
|
||||||
|
const { toast } = useToast()
|
||||||
|
</script>
|
||||||
|
|
@ -454,6 +454,7 @@ const emit = defineEmits<Emits>();
|
||||||
const { mobile } = useDisplay();
|
const { mobile } = useDisplay();
|
||||||
|
|
||||||
const user = useDirectusUser();
|
const user = useDirectusUser();
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
// Local copy of the interest for editing
|
// Local copy of the interest for editing
|
||||||
const interest = ref<Interest | null>(null);
|
const interest = ref<Interest | null>(null);
|
||||||
|
|
@ -528,12 +529,12 @@ const saveInterest = async () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
alert("Interest saved successfully!");
|
toast.success("Interest saved successfully!");
|
||||||
emit("save", interest.value);
|
emit("save", interest.value);
|
||||||
closeModal();
|
closeModal();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to save interest:", error);
|
console.error("Failed to save interest:", error);
|
||||||
alert("Failed to save interest. Please try again.");
|
toast.error("Failed to save interest. Please try again.");
|
||||||
} finally {
|
} finally {
|
||||||
isSaving.value = false;
|
isSaving.value = false;
|
||||||
}
|
}
|
||||||
|
|
@ -555,11 +556,11 @@ const requestMoreInfoToSales = async () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
alert("Request More Info - To Sales sent successfully!");
|
toast.success("Request More Info - To Sales sent successfully!");
|
||||||
emit("requestMoreInfoToSales", interest.value);
|
emit("requestMoreInfoToSales", interest.value);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to send request:", error);
|
console.error("Failed to send request:", error);
|
||||||
alert("Failed to send request. Please try again.");
|
toast.error("Failed to send request. Please try again.");
|
||||||
} finally {
|
} finally {
|
||||||
isRequestingMoreInfo.value = false;
|
isRequestingMoreInfo.value = false;
|
||||||
}
|
}
|
||||||
|
|
@ -581,11 +582,11 @@ const requestMoreInformation = async () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
alert("Request More Information sent successfully!");
|
toast.success("Request More Information sent successfully!");
|
||||||
emit("requestMoreInformation", interest.value);
|
emit("requestMoreInformation", interest.value);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to send request:", error);
|
console.error("Failed to send request:", error);
|
||||||
alert("Failed to send request. Please try again.");
|
toast.error("Failed to send request. Please try again.");
|
||||||
} finally {
|
} finally {
|
||||||
isRequestingMoreInformation.value = false;
|
isRequestingMoreInformation.value = false;
|
||||||
}
|
}
|
||||||
|
|
@ -607,11 +608,11 @@ const eoiSendToSales = async () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
alert("EOI Send to Sales sent successfully!");
|
toast.success("EOI Send to Sales sent successfully!");
|
||||||
emit("eoiSendToSales", interest.value);
|
emit("eoiSendToSales", interest.value);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to send EOI:", error);
|
console.error("Failed to send EOI:", error);
|
||||||
alert("Failed to send EOI. Please try again.");
|
toast.error("Failed to send EOI. Please try again.");
|
||||||
} finally {
|
} finally {
|
||||||
isSendingEOI.value = false;
|
isSendingEOI.value = false;
|
||||||
}
|
}
|
||||||
|
|
@ -725,7 +726,7 @@ const updateBerths = async (newBerths: number[]) => {
|
||||||
originalBerths.value = [...newBerths];
|
originalBerths.value = [...newBerths];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update berths:", error);
|
console.error("Failed to update berths:", error);
|
||||||
alert("Failed to update berths. Please try again.");
|
toast.error("Failed to update berths. Please try again.");
|
||||||
// Revert to original values on error
|
// Revert to original values on error
|
||||||
selectedBerths.value = [...originalBerths.value];
|
selectedBerths.value = [...originalBerths.value];
|
||||||
}
|
}
|
||||||
|
|
@ -776,7 +777,7 @@ const updateBerthRecommendations = async (newRecommendations: number[]) => {
|
||||||
originalBerthRecommendations.value = [...newRecommendations];
|
originalBerthRecommendations.value = [...newRecommendations];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to update berth recommendations:", error);
|
console.error("Failed to update berth recommendations:", error);
|
||||||
alert("Failed to update berth recommendations. Please try again.");
|
toast.error("Failed to update berth recommendations. Please try again.");
|
||||||
// Revert to original values on error
|
// Revert to original values on error
|
||||||
selectedBerthRecommendations.value = [
|
selectedBerthRecommendations.value = [
|
||||||
...originalBerthRecommendations.value,
|
...originalBerthRecommendations.value,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<v-chip :color="badgeColor" small>
|
<v-chip :color="badgeColor" small>
|
||||||
{{ salesProcessLevel }}
|
{{ salesProcessLevel || 'No Status' }}
|
||||||
</v-chip>
|
</v-chip>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -18,7 +18,7 @@ type InterestSalesProcessLevel =
|
||||||
| "Contract Signed";
|
| "Contract Signed";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
salesProcessLevel: InterestSalesProcessLevel;
|
salesProcessLevel: InterestSalesProcessLevel | null | undefined;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const colorMapping: Record<InterestSalesProcessLevel, string> = {
|
const colorMapping: Record<InterestSalesProcessLevel, string> = {
|
||||||
|
|
@ -33,7 +33,8 @@ const colorMapping: Record<InterestSalesProcessLevel, string> = {
|
||||||
};
|
};
|
||||||
|
|
||||||
const badgeColor = computed(() => {
|
const badgeColor = computed(() => {
|
||||||
return colorMapping[props.salesProcessLevel] || "grey";
|
if (!props.salesProcessLevel) return "grey";
|
||||||
|
return colorMapping[props.salesProcessLevel as InterestSalesProcessLevel] || "grey";
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
interface Toast {
|
||||||
|
show: boolean
|
||||||
|
message: string
|
||||||
|
color: string
|
||||||
|
timeout: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const toast = ref<Toast>({
|
||||||
|
show: false,
|
||||||
|
message: '',
|
||||||
|
color: 'success',
|
||||||
|
timeout: 3000
|
||||||
|
})
|
||||||
|
|
||||||
|
export const useToast = () => {
|
||||||
|
const showToast = (message: string, color: string = 'success', timeout: number = 3000) => {
|
||||||
|
toast.value = {
|
||||||
|
show: true,
|
||||||
|
message,
|
||||||
|
color,
|
||||||
|
timeout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = (message: string) => {
|
||||||
|
showToast(message, 'success')
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = (message: string) => {
|
||||||
|
showToast(message, 'error')
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = (message: string) => {
|
||||||
|
showToast(message, 'info')
|
||||||
|
}
|
||||||
|
|
||||||
|
const warning = (message: string) => {
|
||||||
|
showToast(message, 'warning')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
toast,
|
||||||
|
showToast,
|
||||||
|
success,
|
||||||
|
error,
|
||||||
|
info,
|
||||||
|
warning
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
<v-container fluid>
|
<v-container fluid>
|
||||||
<!-- Header Section -->
|
<!-- Header Section -->
|
||||||
<v-row class="mb-6">
|
<v-row class="mb-6">
|
||||||
<v-col cols="12">
|
<v-col cols="12" md="8">
|
||||||
<h1 class="text-h4 font-weight-bold mb-2">
|
<h1 class="text-h4 font-weight-bold mb-2">
|
||||||
<v-icon class="mr-2" color="primary">mdi-account-group</v-icon>
|
<v-icon class="mr-2" color="primary">mdi-account-group</v-icon>
|
||||||
Interest List
|
Interest List
|
||||||
|
|
@ -12,6 +12,17 @@
|
||||||
Manage and track all potential client interests
|
Manage and track all potential client interests
|
||||||
</p>
|
</p>
|
||||||
</v-col>
|
</v-col>
|
||||||
|
<v-col cols="12" md="4" class="d-flex justify-end align-center">
|
||||||
|
<v-btn
|
||||||
|
color="primary"
|
||||||
|
size="large"
|
||||||
|
@click="showCreateModal = true"
|
||||||
|
prepend-icon="mdi-plus"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
|
Add Interest
|
||||||
|
</v-btn>
|
||||||
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
|
|
||||||
<!-- Search and Filters Section -->
|
<!-- Search and Filters Section -->
|
||||||
|
|
@ -182,6 +193,12 @@
|
||||||
:selected-interest="selectedInterest"
|
:selected-interest="selectedInterest"
|
||||||
@save="handleSaveInterest"
|
@save="handleSaveInterest"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Create Interest Modal -->
|
||||||
|
<CreateInterestModal
|
||||||
|
v-model="showCreateModal"
|
||||||
|
@created="handleInterestCreated"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -189,6 +206,7 @@
|
||||||
import LeadCategoryBadge from "~/components/LeadCategoryBadge.vue";
|
import LeadCategoryBadge from "~/components/LeadCategoryBadge.vue";
|
||||||
import InterestSalesBadge from "~/components/InterestSalesBadge.vue";
|
import InterestSalesBadge from "~/components/InterestSalesBadge.vue";
|
||||||
import InterestDetailsModal from "~/components/InterestDetailsModal.vue";
|
import InterestDetailsModal from "~/components/InterestDetailsModal.vue";
|
||||||
|
import CreateInterestModal from "~/components/CreateInterestModal.vue";
|
||||||
import { useFetch } from "#app";
|
import { useFetch } from "#app";
|
||||||
import { ref, computed } from "vue";
|
import { ref, computed } from "vue";
|
||||||
import type { Interest } from "@/utils/types";
|
import type { Interest } from "@/utils/types";
|
||||||
|
|
@ -201,6 +219,7 @@ const user = useDirectusUser();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const showModal = ref(false);
|
const showModal = ref(false);
|
||||||
|
const showCreateModal = ref(false);
|
||||||
const selectedInterest = ref<Interest | null>(null);
|
const selectedInterest = ref<Interest | null>(null);
|
||||||
const selectedSalesLevel = ref('all');
|
const selectedSalesLevel = ref('all');
|
||||||
|
|
||||||
|
|
@ -231,6 +250,13 @@ const handleSaveInterest = async (interest: Interest) => {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleInterestCreated = async (interest: Interest) => {
|
||||||
|
// Refresh the interests data to include the new interest
|
||||||
|
loading.value = true;
|
||||||
|
await refresh();
|
||||||
|
loading.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
const headers = [
|
const headers = [
|
||||||
{ title: "Contact", key: "Full Name", sortable: true, width: "25%" },
|
{ title: "Contact", key: "Full Name", sortable: true, width: "25%" },
|
||||||
{ title: "Yacht", key: "Yacht Name", sortable: true },
|
{ title: "Yacht", key: "Yacht Name", sortable: true },
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,21 @@
|
||||||
<template>
|
<template>
|
||||||
<v-container fluid class="pa-4">
|
<v-container fluid class="pa-4">
|
||||||
|
<!-- Loading Indicator -->
|
||||||
|
<v-overlay
|
||||||
|
v-model="loading"
|
||||||
|
persistent
|
||||||
|
class="align-center justify-center"
|
||||||
|
>
|
||||||
|
<v-progress-circular
|
||||||
|
indeterminate
|
||||||
|
size="64"
|
||||||
|
color="primary"
|
||||||
|
></v-progress-circular>
|
||||||
|
</v-overlay>
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<v-row class="mb-4">
|
<v-row class="mb-4">
|
||||||
<v-col cols="12">
|
<v-col cols="12" md="8">
|
||||||
<h1 class="text-h4 font-weight-bold mb-2">
|
<h1 class="text-h4 font-weight-bold mb-2">
|
||||||
<v-icon class="mr-2" color="primary">mdi-view-column</v-icon>
|
<v-icon class="mr-2" color="primary">mdi-view-column</v-icon>
|
||||||
Interest Status Board
|
Interest Status Board
|
||||||
|
|
@ -11,6 +24,17 @@
|
||||||
Track interests through the sales pipeline
|
Track interests through the sales pipeline
|
||||||
</p>
|
</p>
|
||||||
</v-col>
|
</v-col>
|
||||||
|
<v-col cols="12" md="4" class="d-flex justify-end align-center">
|
||||||
|
<v-btn
|
||||||
|
color="primary"
|
||||||
|
size="large"
|
||||||
|
@click="showCreateModal = true"
|
||||||
|
prepend-icon="mdi-plus"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
|
Add Interest
|
||||||
|
</v-btn>
|
||||||
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
|
|
||||||
<!-- Kanban Board -->
|
<!-- Kanban Board -->
|
||||||
|
|
@ -148,13 +172,20 @@
|
||||||
:selected-interest="selectedInterest"
|
:selected-interest="selectedInterest"
|
||||||
@save="handleSaveInterest"
|
@save="handleSaveInterest"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Create Interest Modal -->
|
||||||
|
<CreateInterestModal
|
||||||
|
v-model="showCreateModal"
|
||||||
|
@created="handleInterestCreated"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import InterestDetailsModal from "~/components/InterestDetailsModal.vue";
|
import InterestDetailsModal from "~/components/InterestDetailsModal.vue";
|
||||||
|
import CreateInterestModal from "~/components/CreateInterestModal.vue";
|
||||||
import InterestSalesBadge from "~/components/InterestSalesBadge.vue";
|
import InterestSalesBadge from "~/components/InterestSalesBadge.vue";
|
||||||
import { useFetch } from "#app";
|
import { useFetch } from "#app";
|
||||||
import { ref, computed } from "vue";
|
import { ref, computed, onMounted } from "vue";
|
||||||
import type { Interest, InterestSalesProcessLevel, InterestsResponse } from "@/utils/types";
|
import type { Interest, InterestSalesProcessLevel, InterestsResponse } from "@/utils/types";
|
||||||
import { InterestSalesProcessLevelFlow } from "@/utils/types";
|
import { InterestSalesProcessLevelFlow } from "@/utils/types";
|
||||||
|
|
||||||
|
|
@ -165,11 +196,12 @@ useHead({
|
||||||
const user = useDirectusUser();
|
const user = useDirectusUser();
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const showModal = ref(false);
|
const showModal = ref(false);
|
||||||
|
const showCreateModal = ref(false);
|
||||||
const selectedInterest = ref<Interest | null>(null);
|
const selectedInterest = ref<Interest | null>(null);
|
||||||
const draggedInterest = ref<Interest | null>(null);
|
const draggedInterest = ref<Interest | null>(null);
|
||||||
const draggedFromLevel = ref<string | null>(null);
|
const draggedFromLevel = ref<string | null>(null);
|
||||||
|
|
||||||
const { data: interests } = useFetch<InterestsResponse>("/api/get-interests", {
|
const { data: interests, refresh } = useFetch<InterestsResponse>("/api/get-interests", {
|
||||||
headers: {
|
headers: {
|
||||||
"x-tag": user.value?.email ? "094ut234" : "pjnvü1230",
|
"x-tag": user.value?.email ? "094ut234" : "pjnvü1230",
|
||||||
},
|
},
|
||||||
|
|
@ -181,6 +213,11 @@ const { data: interests } = useFetch<InterestsResponse>("/api/get-interests", {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Set loading to true initially
|
||||||
|
onMounted(() => {
|
||||||
|
loading.value = true;
|
||||||
|
});
|
||||||
|
|
||||||
const groupedInterests = computed(() => {
|
const groupedInterests = computed(() => {
|
||||||
const groups: Record<string, Interest[]> = {};
|
const groups: Record<string, Interest[]> = {};
|
||||||
|
|
||||||
|
|
@ -279,12 +316,19 @@ const handleInterestClick = (interestId: number) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Event handlers for the modal
|
// Event handlers for the modal
|
||||||
const handleSaveInterest = (interest: Interest) => {
|
const handleSaveInterest = async (interest: Interest) => {
|
||||||
// Update the interest in the local list if needed
|
// Update the interest in the local list if needed
|
||||||
// The modal component already handles the API call and shows success/error messages
|
// The modal component already handles the API call and shows success/error messages
|
||||||
// You can add additional logic here if needed
|
// You can add additional logic here if needed
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleInterestCreated = async (interest: Interest) => {
|
||||||
|
// Refresh the interests data to include the new interest
|
||||||
|
loading.value = true;
|
||||||
|
await refresh();
|
||||||
|
loading.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
// Drag and Drop handlers
|
// Drag and Drop handlers
|
||||||
const handleDragStart = (event: DragEvent, interest: Interest) => {
|
const handleDragStart = (event: DragEvent, interest: Interest) => {
|
||||||
draggedInterest.value = interest;
|
draggedInterest.value = interest;
|
||||||
|
|
@ -335,19 +379,37 @@ const handleDrop = async (event: DragEvent, targetLevel: string) => {
|
||||||
event.currentTarget.classList.remove('drag-over');
|
event.currentTarget.classList.remove('drag-over');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!draggedInterest.value || draggedFromLevel.value === targetLevel) {
|
// Get the dragged interest data from dataTransfer
|
||||||
|
let draggedData: Interest | null = null;
|
||||||
|
try {
|
||||||
|
const dataString = event.dataTransfer?.getData('text/plain');
|
||||||
|
if (dataString) {
|
||||||
|
draggedData = JSON.parse(dataString);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse dragged data:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use either the parsed data or the ref value
|
||||||
|
const interestToMove = draggedData || draggedInterest.value;
|
||||||
|
const sourceLevel = draggedData?.['Sales Process Level'] || draggedFromLevel.value;
|
||||||
|
|
||||||
|
if (!interestToMove || !interestToMove.Id || sourceLevel === targetLevel) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Store the interest ID before any async operations
|
||||||
|
const interestId = interestToMove.Id;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Update the interest's sales process level
|
// Update the interest's sales process level
|
||||||
const response = await $fetch('/api/update-interest', {
|
const response = await $fetch('/api/update-interest', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'x-tag': '094ut234'
|
'x-tag': user.value?.email ? "094ut234" : "pjnvü1230"
|
||||||
},
|
},
|
||||||
body: {
|
body: {
|
||||||
id: draggedInterest.value.Id,
|
id: interestId,
|
||||||
data: {
|
data: {
|
||||||
'Sales Process Level': targetLevel
|
'Sales Process Level': targetLevel
|
||||||
}
|
}
|
||||||
|
|
@ -355,19 +417,19 @@ const handleDrop = async (event: DragEvent, targetLevel: string) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update local data
|
// Update local data
|
||||||
const interestIndex = interests.value?.list.findIndex(i => i.Id === draggedInterest.value!.Id);
|
const interestIndex = interests.value?.list.findIndex(i => i.Id === interestId);
|
||||||
if (interestIndex !== undefined && interestIndex !== -1 && interests.value) {
|
if (interestIndex !== undefined && interestIndex !== -1 && interests.value) {
|
||||||
interests.value.list[interestIndex]['Sales Process Level'] = targetLevel;
|
interests.value.list[interestIndex]['Sales Process Level'] = targetLevel;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show success message
|
// Show success message
|
||||||
const { $toast } = useNuxtApp();
|
const toast = useToast();
|
||||||
$toast.success(`Moved to ${targetLevel}`);
|
toast.success(`Moved to ${targetLevel}`);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to update interest:', error);
|
console.error('Failed to update interest:', error);
|
||||||
const { $toast } = useNuxtApp();
|
const toast = useToast();
|
||||||
$toast.error('Failed to update interest status');
|
toast.error('Failed to update interest status');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { createInterest } from "../utils/nocodb";
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
if (!body || Object.keys(body).length === 0) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: "No data provided" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdInterest = await createInterest(body);
|
||||||
|
return createdInterest;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
throw createError({ statusCode: 500, statusMessage: error.message });
|
||||||
|
} else {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 500,
|
||||||
|
statusMessage: "An unexpected error occurred",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -41,39 +41,103 @@ export const updateInterest = async (id: string, data: Partial<Interest>) => {
|
||||||
// Create a clean data object that matches the InterestsRequest schema
|
// Create a clean data object that matches the InterestsRequest schema
|
||||||
// Remove any properties that are not in the schema or shouldn't be sent
|
// Remove any properties that are not in the schema or shouldn't be sent
|
||||||
const cleanData: Record<string, any> = {};
|
const cleanData: Record<string, any> = {};
|
||||||
|
|
||||||
// Only include fields that are part of the InterestsRequest schema
|
// Only include fields that are part of the InterestsRequest schema
|
||||||
const allowedFields = [
|
const allowedFields = [
|
||||||
"Full Name", "Yacht Name", "Length", "Address", "Email Address",
|
"Full Name",
|
||||||
"Sales Process Level", "Phone Number", "Extra Comments", "Berth Size Desired",
|
"Yacht Name",
|
||||||
"LOI-NDA Document", "Date Added", "Width", "Depth", "Created At",
|
"Length",
|
||||||
"Request More Information", "Source", "Place of Residence",
|
"Address",
|
||||||
"Contact Method Preferred", "Request Form Sent", "Berth Number",
|
"Email Address",
|
||||||
"EOI Time Sent", "Lead Category", "Request More Info - To Sales",
|
"Sales Process Level",
|
||||||
"EOI Send to Sales", "Time LOI Sent"
|
"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
|
// Filter the data to only include allowed fields
|
||||||
for (const field of allowedFields) {
|
for (const field of allowedFields) {
|
||||||
if (field in data) {
|
if (field in data) {
|
||||||
cleanData[field] = data[field];
|
cleanData[field] = data[field];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $fetch<Interest>(createTableUrl(Table.Interest), {
|
return $fetch<Interest>(createTableUrl(Table.Interest), {
|
||||||
method: 'PATCH',
|
method: "PATCH",
|
||||||
headers: {
|
headers: {
|
||||||
"xc-token": getNocoDbConfiguration().token,
|
"xc-token": getNocoDbConfiguration().token,
|
||||||
},
|
},
|
||||||
body: {
|
body: {
|
||||||
Id: id, // This identifies the record to update
|
Id: id, // This identifies the record to update
|
||||||
...cleanData // These are the fields to update
|
...cleanData, // These are the fields to update
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const createInterest = async (data: Partial<Interest>) => {
|
||||||
|
// Create a clean data object that matches the InterestsRequest schema
|
||||||
|
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",
|
||||||
|
"Date Added",
|
||||||
|
"Width",
|
||||||
|
"Depth",
|
||||||
|
"Source",
|
||||||
|
"Place of Residence",
|
||||||
|
"Contact Method Preferred",
|
||||||
|
"Lead Category",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Filter the data to only include allowed fields
|
||||||
|
for (const field of allowedFields) {
|
||||||
|
if (field in data) {
|
||||||
|
cleanData[field] = data[field];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove any computed or relation fields that shouldn't be sent
|
||||||
|
delete cleanData.Id;
|
||||||
|
delete cleanData.Berths;
|
||||||
|
delete cleanData["Berth Recommendations"];
|
||||||
|
delete cleanData.Berth;
|
||||||
|
|
||||||
|
return $fetch<Interest>(createTableUrl(Table.Interest), {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"xc-token": getNocoDbConfiguration().token,
|
||||||
|
},
|
||||||
|
body: cleanData,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const triggerWebhook = async (url: string, payload: any) =>
|
export const triggerWebhook = async (url: string, payload: any) =>
|
||||||
$fetch(url, {
|
$fetch(url, {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
body: payload,
|
body: payload,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue