feat: add interest button

This commit is contained in:
Ron 2025-06-03 22:04:22 +03:00
parent 0437ba6462
commit bc0fa6fbe0
10 changed files with 653 additions and 40 deletions

View File

@ -1,4 +1,5 @@
<template>
<NuxtPwaManifest />
<NuxtPage />
<GlobalToast />
</template>

View File

@ -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>

View File

@ -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>

View File

@ -454,6 +454,7 @@ const emit = defineEmits<Emits>();
const { mobile } = useDisplay();
const user = useDirectusUser();
const toast = useToast();
// Local copy of the interest for editing
const interest = ref<Interest | null>(null);
@ -528,12 +529,12 @@ const saveInterest = async () => {
},
});
alert("Interest saved successfully!");
toast.success("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.");
toast.error("Failed to save interest. Please try again.");
} finally {
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);
} catch (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 {
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);
} catch (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 {
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);
} catch (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 {
isSendingEOI.value = false;
}
@ -725,7 +726,7 @@ const updateBerths = async (newBerths: number[]) => {
originalBerths.value = [...newBerths];
} catch (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
selectedBerths.value = [...originalBerths.value];
}
@ -776,7 +777,7 @@ const updateBerthRecommendations = async (newRecommendations: number[]) => {
originalBerthRecommendations.value = [...newRecommendations];
} catch (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
selectedBerthRecommendations.value = [
...originalBerthRecommendations.value,

View File

@ -1,6 +1,6 @@
<template>
<v-chip :color="badgeColor" small>
{{ salesProcessLevel }}
{{ salesProcessLevel || 'No Status' }}
</v-chip>
</template>
@ -18,7 +18,7 @@ type InterestSalesProcessLevel =
| "Contract Signed";
const props = defineProps<{
salesProcessLevel: InterestSalesProcessLevel;
salesProcessLevel: InterestSalesProcessLevel | null | undefined;
}>();
const colorMapping: Record<InterestSalesProcessLevel, string> = {
@ -33,7 +33,8 @@ const colorMapping: Record<InterestSalesProcessLevel, string> = {
};
const badgeColor = computed(() => {
return colorMapping[props.salesProcessLevel] || "grey";
if (!props.salesProcessLevel) return "grey";
return colorMapping[props.salesProcessLevel as InterestSalesProcessLevel] || "grey";
});
</script>

51
composables/useToast.ts Normal file
View File

@ -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
}
}

View File

@ -3,7 +3,7 @@
<v-container fluid>
<!-- Header Section -->
<v-row class="mb-6">
<v-col cols="12">
<v-col cols="12" md="8">
<h1 class="text-h4 font-weight-bold mb-2">
<v-icon class="mr-2" color="primary">mdi-account-group</v-icon>
Interest List
@ -12,6 +12,17 @@
Manage and track all potential client interests
</p>
</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>
<!-- Search and Filters Section -->
@ -182,6 +193,12 @@
:selected-interest="selectedInterest"
@save="handleSaveInterest"
/>
<!-- Create Interest Modal -->
<CreateInterestModal
v-model="showCreateModal"
@created="handleInterestCreated"
/>
</div>
</template>
@ -189,6 +206,7 @@
import LeadCategoryBadge from "~/components/LeadCategoryBadge.vue";
import InterestSalesBadge from "~/components/InterestSalesBadge.vue";
import InterestDetailsModal from "~/components/InterestDetailsModal.vue";
import CreateInterestModal from "~/components/CreateInterestModal.vue";
import { useFetch } from "#app";
import { ref, computed } from "vue";
import type { Interest } from "@/utils/types";
@ -201,6 +219,7 @@ const user = useDirectusUser();
const router = useRouter();
const loading = ref(true);
const showModal = ref(false);
const showCreateModal = ref(false);
const selectedInterest = ref<Interest | null>(null);
const selectedSalesLevel = ref('all');
@ -231,6 +250,13 @@ const handleSaveInterest = async (interest: Interest) => {
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 = [
{ title: "Contact", key: "Full Name", sortable: true, width: "25%" },
{ title: "Yacht", key: "Yacht Name", sortable: true },

View File

@ -1,8 +1,21 @@
<template>
<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 -->
<v-row class="mb-4">
<v-col cols="12">
<v-col cols="12" md="8">
<h1 class="text-h4 font-weight-bold mb-2">
<v-icon class="mr-2" color="primary">mdi-view-column</v-icon>
Interest Status Board
@ -11,6 +24,17 @@
Track interests through the sales pipeline
</p>
</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>
<!-- Kanban Board -->
@ -148,13 +172,20 @@
:selected-interest="selectedInterest"
@save="handleSaveInterest"
/>
<!-- Create Interest Modal -->
<CreateInterestModal
v-model="showCreateModal"
@created="handleInterestCreated"
/>
</template>
<script lang="ts" setup>
import InterestDetailsModal from "~/components/InterestDetailsModal.vue";
import CreateInterestModal from "~/components/CreateInterestModal.vue";
import InterestSalesBadge from "~/components/InterestSalesBadge.vue";
import { useFetch } from "#app";
import { ref, computed } from "vue";
import { ref, computed, onMounted } from "vue";
import type { Interest, InterestSalesProcessLevel, InterestsResponse } from "@/utils/types";
import { InterestSalesProcessLevelFlow } from "@/utils/types";
@ -165,11 +196,12 @@ useHead({
const user = useDirectusUser();
const loading = ref(true);
const showModal = ref(false);
const showCreateModal = 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", {
const { data: interests, refresh } = useFetch<InterestsResponse>("/api/get-interests", {
headers: {
"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 groups: Record<string, Interest[]> = {};
@ -279,12 +316,19 @@ const handleInterestClick = (interestId: number) => {
};
// Event handlers for the modal
const handleSaveInterest = (interest: Interest) => {
const handleSaveInterest = async (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 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
const handleDragStart = (event: DragEvent, interest: Interest) => {
draggedInterest.value = interest;
@ -335,19 +379,37 @@ const handleDrop = async (event: DragEvent, targetLevel: string) => {
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;
}
// Store the interest ID before any async operations
const interestId = interestToMove.Id;
try {
// Update the interest's sales process level
const response = await $fetch('/api/update-interest', {
method: 'POST',
headers: {
'x-tag': '094ut234'
'x-tag': user.value?.email ? "094ut234" : "pjnvü1230"
},
body: {
id: draggedInterest.value.Id,
id: interestId,
data: {
'Sales Process Level': targetLevel
}
@ -355,19 +417,19 @@ const handleDrop = async (event: DragEvent, targetLevel: string) => {
});
// 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) {
interests.value.list[interestIndex]['Sales Process Level'] = targetLevel;
}
// Show success message
const { $toast } = useNuxtApp();
$toast.success(`Moved to ${targetLevel}`);
const toast = useToast();
toast.success(`Moved to ${targetLevel}`);
} catch (error) {
console.error('Failed to update interest:', error);
const { $toast } = useNuxtApp();
$toast.error('Failed to update interest status');
const toast = useToast();
toast.error('Failed to update interest status');
}
};
</script>

View File

@ -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",
});
}
}
});

View File

@ -41,39 +41,103 @@ 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"
"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',
method: "PATCH",
headers: {
"xc-token": getNocoDbConfiguration().token,
},
body: {
Id: id, // This identifies the record to update
...cleanData // These are the fields to update
Id: id, // This identifies the record 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) =>
$fetch(url, {
method: 'POST',
method: "POST",
body: payload,
});