work on customer editor #6

This commit is contained in:
2025-11-21 08:39:34 +01:00
parent 4e578b6cbe
commit 34fa8d6a0d
5 changed files with 291 additions and 129 deletions
+214 -78
View File
@@ -1,9 +1,25 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUpdated, useTemplateRef } from "vue"
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'
import { ref, computed, watch } from "vue"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter, DialogClose } from "@/components/ui/dialog"
import { Button } from '@/components/ui/crm-button'
import { Textarea } from "@/components/ui/textarea";
import { Input } from '@/components/ui/input';
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput, InputGroupText, InputGroupTextarea } from '@/components/ui/crm-input-group'
import { Customer } from '@/types'
import { Check, Ellipsis, Trash } from "lucide-vue-next"
import { toast } from "vue-sonner"
import { alertStore } from "@/stores/alertStore"
import { Trash2, Loader2, Ellipsis, Check, ImportIcon, Mail, Notebook, Send, CornerDownLeft, Plus, CirclePlus, Phone, PhoneCall } from "lucide-vue-next"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'
import DialogCloseButton from "./DialogCloseButton/DialogCloseButton.vue";
import { Table, TableCell, TableFooter, TableHead, TableHeader, TableRow, } from '@/components/ui/table';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { Separator } from '@/components/ui/separator'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { bgColorForString, toLocalDate, randomDate, loremIpsum } from '@/lib/utils'
import { getInitials } from '@/composables/useInitials';
import GrowingTextarea from "./ui/growing-textarea/GrowingTextarea.vue";
import { Kbd, KbdGroup } from '@/components/ui/kbd'
import Checkbox from "./ui/checkbox/Checkbox.vue";
const props = defineProps<{
@@ -16,6 +32,9 @@ const emit = defineEmits(['update:modelValue', 'update:open', 'save', 'cancel',
const customer = ref<Customer | null>(props.customerData)
const isDirty = ref(false);
const isLoading = ref(false);
const isSaving = ref(false)
const alert = alertStore()
const isTakingNote = ref(false)
const isOpen = computed({
get: () => props.modelValue,
@@ -25,49 +44,60 @@ const isOpen = computed({
})
// watch for new external invoice data
watch(() => props.customerData as Customer,
(newValue, oldValue) => {
if (newValue == oldValue) return
customer.value = newValue
watch(() => props.modelValue, (open) => {
// on open
if (open) {
// Set initial state for a newly opened document
isDirty.value = false
isLoading.value = true
// console.log("customerData", "Dirty: " + isDirty.value, "loading: " + isLoading.value)
customer.value = props.customerData
}
)
watch(customer, (newValue) => {
// console.log(newValue)
})
const saveChanges = () => {
// if (invoice.value) {
// emit('save', invoice.value)
// isOpen.value = false
// }
watch(customer, (newValue) => {
// isDirty.value = true
})
const save = () => {
if (customer.value) {
// add spinner to save button
isSaving.value = true
try {
// TODO: do the actual saving
emit('save', invoice.value)
} catch (error) {
toast.error("Rechnung konnte nicht gespeichert werden", {
description: (error as Error).message,
})
} finally {
// remove spinner from save button
isSaving.value = false
setTimeout(() => { isDirty.value = false }, 1000)
}
}
}
const cancelChanges = (event: Event | null) => {
// if (isDirty.value) {
// alert.value.title = "Wirklich schließen?"
// alert.value.message = "Es gibt ungespeicherte Änderungen, die dann verloren gehen."
// alert.value.cancelText = "Abbrechen"
// alert.value.onCancel = () => {
// event?.preventDefault()
// event.returnValue = true
// alert.value.open = false
// }
// alert.value.confirmText = "Schließen"
// alert.value.onConfirm = () => {
// emit('cancel')
// isOpen.value = false
// alert.value.open = false
// }
// alert.value.open = true
// } else
const cancel = (event: Event | null) => {
if (!event) return
event.preventDefault()
event.returnValue = true
if (isDirty.value) {
alert.show(
"Wirklich schließen?",
"Es gibt ungespeicherte Änderungen, die dann verloren gehen.",
{
actionText: "Änderungen verwerfen",
onAction: () => {
emit('cancel')
isOpen.value = false
}
}
)
} else {
emit('cancel')
isOpen.value = false
}
@@ -80,7 +110,7 @@ const handleLogoUpload = (event: Event) => {
// Hier könntest du die Datei validieren (Größe, Typ, etc.)
if (file.size > 2 * 1024 * 1024) { // 2 MB
alert('Die Datei ist zu groß. Maximal 2MB erlaubt.');
toast.warning('Die Datei ist zu groß', { description: 'Maximal 2 MB erlaubt' });
return;
}
@@ -102,44 +132,69 @@ const handleLogoUpload = (event: Event) => {
<template>
<Dialog id="customer-dialog" v-model:open="isOpen">
<!-- auto_480px weniger breit, wenn customer.id == 0 -->
<DialogContent
class="sm:max-w-[min((100%-2rem),1152px)] grid-rows-[auto_minmax(0,1fr)_auto] p-0 h-[calc(100dvh-2rem)]"
@escapeKeyDown="cancelChanges" @interactOutside="cancelChanges">
class="sm:max-w-[min((100%-2rem),1344px)] grid-rows-[auto_minmax(0,1fr)_auto] h-[calc(100dvh-2rem)] gap-0 p-0 outline-none"
@escapeKeyDown="cancel" @interactOutside="cancel">
<DialogHeader class="px-3 pt-3 flex flex-row justify-end">
<DialogTitle class="sr-only">Kunde</DialogTitle>
<DialogHeader class="p-4 md:p-6 lg:p-12 pb-0! flex flex-row items-center gap-8">
<div v-if="customer && customer.id > 0" class="hidden md:flex mr-4 gap-2">
<Button :size="'sm'" variant="action" @click="" class="hidden" :class="{ flex: isDirty }">
<Check :strokeWidth="1.5" class="text-current" />
<span>Speichern</span>
<div class="flex flex-col grow">
<DialogTitle class="text-primary-foreground font-bold text-left">
<Input v-if="customer" v-model="customer.companyName as string"
class="text-primary-foreground text-lg! w-full text-ellipsis px-0 bg-transparent dark:bg-transparent hover:bg-accent dark:hover:bg-accent/30 border-none shadow-none"
type="text" placeholder="Firmenname" />
</DialogTitle>
<DialogDescription class="sr-only">
{{ customer?.companyName || '' }}
</DialogDescription>
</div>
<div class="flex gap-2 items-center">
<TooltipProvider>
<!-- Save -->
<Button v-if="customer && isDirty" class="grow md:grow-0" size="sm" @click="save"
:disabled="isSaving">
<Loader2 v-if="isSaving" stroke-width="1.5" class="animate-spin" />
<Check v-else stroke-width="1.5" />
Speichern
</Button>
<Button :size="'sm'" variant="destructive" @click="">
<Trash :strokeWidth="1.5" class="text-current" />
<span>Löschen</span>
</Button>
<Button :size="'sm'" variant="ghost" @click="">
<Ellipsis class="text-muted-foreground" />
<!-- Ellipsis menu -->
<DropdownMenu v-if="customer && customer.id > 0">
<DropdownMenuTrigger>
<Button variant="ghost" size="sm" class="px-0! w-7 ml-2">
<Ellipsis class="size-4" stroke-width="1.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<!-- Delete -->
<DropdownMenuItem
class="flex justify-between text-destructive! hover:bg-red-100! dark:hover:bg-red-950!"
@click="">
<div class="flex items-center gap-3">
<Trash2 :strokeWidth="1.5" class="text-current" />
<span class="mr-2">Löschen</span>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TooltipProvider>
<DialogClose as-child>
<DialogCloseButton />
</DialogClose>
</div>
</DialogHeader>
<div class="overflow-y-auto px-6">
<div
class="block sticky top-0 py-4 bg-slate-100 bg-white dark:bg-neutral-800 z-1 flex items-end gap-12">
<div class="grow">
<DialogTitle class="text-xl text-primary-foreground font-bold">Edit profile</DialogTitle>
<DialogDescription>
Make changes to your profile here. Click save when you're done.
</DialogDescription>
</div>
</div>
<div class="grid grid-cols-[auto_480px] px-6 md:p-12 gap-6">
<main class="overflow-y-auto pt-0! pr-3" v-if="customer">
<div class="flex-none md:flex gap-12 p-6 bg-slate-100 dark:bg-neutral-900 rounded-lg">
<div class="flex-none md:flex gap-12 mt-6 p-6 bg-slate-100 dark:bg-neutral-900 rounded-lg">
<div class="space-y-4 w-full">
<!-- Logo Upload -->
<div>
@@ -161,14 +216,11 @@ const handleLogoUpload = (event: Event) => {
</div>
</div>
<!-- Firmenname -->
<div>
<label
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Firmenname</label>
<input type="text" v-model="customer.companyName"
class="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-neutral-800 dark:border-neutral-700">
</div>
<div class="space-y-4 w-full">
<!-- Kunden-Nummer -->
<div>
<label
@@ -290,22 +342,106 @@ const handleLogoUpload = (event: Event) => {
</select>
</div>
<!-- Notizen -->
<div class="mt-6">
<label
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Notizen</label>
<textarea v-model="customer.notes" rows="4"
class="w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-neutral-800 dark:border-neutral-700"></textarea>
</div>
</div>
</div>
<div class="px-4 mt-6">
Contacts hier
</div>
</main>
<aside class="bg-sidebar rounded h-full min-h-0 p-6 flex flex-col">
<!-- Todo -->
<div>
<div class="flex justify-between items-center mb-6">
<h2 class="font-bold">Nächste Schritte</h2>
<Button variant="ghost">
<Plus stroke-width="1.5" />
</Button>
</div>
<ul class="flex flex-col">
<li v-for="i in 3">
<div
class="grid grid-cols-[calc(var(--spacing)_*_6)_auto] gap-y-0 gap-x-2 items-start border-b-1 border-foreground/20 py-2.5 pl-1 pr-2">
<div
class="mt-1 row-span-2 w-5 aspect-square rounded-full border-muted-foreground border-1 flex items-center justify-center">
<div
class="w-3.5 relative aspect-square rounded-full bg-transparent has-[input:checked]:bg-muted-foreground">
<input type="checkbox" class="absolute -inset-2 opacity-0">
</div>
</div>
<div class="flex items-center gap-2">
<Input :value="loremIpsum(Math.random() * 5 + 1)"
class="my-0 px-0 text-base! h-6 border-0 outline-0 shadow-none" />
<!-- <PhoneCall stroke-width="1.5" :size="16" class="text-muted-foreground" /> -->
<!-- <Check stroke-width="1.5" :size="16" class="text-muted-foreground" /> -->
<Mail stroke-width="1.5" :size="16" class="text-muted-foreground" />
</div>
<div class="text-sm text-muted-foreground">{{ toLocalDate(randomDate()) }}</div>
</div>
</li>
</ul>
</div>
<Separator class="bg-transparent my-6" />
<!-- Write note -->
<div>
<div class="flex justify-between items-center mb-6">
<h2 class="font-bold">Notizen</h2>
<Button variant="ghost" @click="isTakingNote = !isTakingNote">
<Plus stroke-width="1.5" />
</Button>
</div>
<div :class="{ 'h-0': !isTakingNote }"
class="my-6 flex flex-col items-end gap-2 overflow-hidden transition-[height] h-[calc-size(auto)]">
<Textarea v-if="customer" v-model="customer.notes" class="bg-background resize-none"
placeholder="Neue Notiz"></Textarea>
<div class="flex gap-3">
<KbdGroup class="ml-2">
<Kbd class="visible-mac"></Kbd>
<Kbd class="visible-pc">Ctrl</Kbd>
<Kbd>
<CornerDownLeft stroke-width="1.5" class="h-3 w-3" />
</Kbd>
</KbdGroup>
<Button class="w-20" size="sm" variant="outline">
<Send stroke-width="1.5" />
</Button>
</div>
</div>
</div>
<!-- Notes -->
<div class="overflow-y-auto flex flex-col gap-6">
<article v-for="i in 5">
<div class="text-muted-foreground text-sm font-medium flex gap-3 items-center mb-1">
<Avatar class="size-6 text-xs">
<AvatarImage :src="'https://i.pravatar.cc/100?img=' + i" loading="lazy" />
<AvatarFallback :class="bgColorForString(getInitials('Daniel Stock'))">
{{ getInitials('Daniel Stock') }}
</AvatarFallback>
</Avatar>
<span>{{ toLocalDate(randomDate()) }}</span>
</div>
<div class="text-sm">
{{ loremIpsum(Math.random() * 30) }}
</div>
</article>
</div>
</aside>
</div>
<DialogFooter></DialogFooter>
</DialogContent>
</Dialog>
</template>
@@ -24,7 +24,7 @@ import { Input } from '@/components/ui/input';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'
import { StatusBadge, statusBadgeLabels } from '@/components/ui/status-badge'
import LineItemTable from '@/components/documents/LineItemTable.vue'
import { Eye, FileText, Trash2, BookUser, User, CodeXml, MessageCircleQuestion, Loader2, Ellipsis, Check, FileCheck, Ban } from "lucide-vue-next"
import { Eye, FileText, Trash2, BookUser, User, CodeXml, MessageCircleQuestion, Loader2, Ellipsis, Check, FileCheck, Ban, Logs } from "lucide-vue-next"
import { alertStore } from "@/stores/alertStore"
import { GrowingTextarea } from '../ui/growing-textarea'
import { toast } from "vue-sonner"
@@ -478,7 +478,7 @@ const updateLineItems = (newItems: LineItem[]) => {
class="sm:max-w-[min((100%-2rem),1152px)] grid-rows-[auto_minmax(0,1fr)_auto] h-[calc(100dvh-2rem)] gap-0 p-0 outline-none"
@escapeKeyDown="cancel" @interactOutside="cancel">
<DialogHeader class="p-4 md:p-6 lg:p-12 pb-0 md:pb-2 lg:pb-8 flex flex-row items-start gap-6">
<DialogHeader class="p-4 md:p-6 lg:p-12 pb-0 md:pb-2 lg:pb-8 flex flex-row items-start gap-12">
<div class="flex flex-col grow">
<DialogTitle class="text-primary-foreground font-bold text-left">
@@ -545,7 +545,7 @@ const updateLineItems = (newItems: LineItem[]) => {
<!-- Ellipsis menu -->
<DropdownMenu>
<DropdownMenu v-if="invoice && invoice.id > 0">
<DropdownMenuTrigger>
<Button variant="ghost" size="sm" class="px-0! w-7 ml-2">
<Ellipsis class="size-4" stroke-width="1.5" />
@@ -598,6 +598,17 @@ const updateLineItems = (newItems: LineItem[]) => {
<DropdownMenuSeparator />
<!-- Audit -->
<DropdownMenuItem v-if="invoice && invoice.paymentStatus != 'draft'"
class="flex justify-between" @click="" disabled>
<div class="flex items-center gap-3">
<Logs stroke-width="1.5" class="text-muted-foreground" />
<span>Audit</span>
</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
<!-- Cancel -->
<DropdownMenuItem
v-if="invoice && ['issued', 'due', 'reminded'].includes(invoice.paymentStatus)"
+17
View File
@@ -106,3 +106,20 @@ export function testToast() {
}, delay * 5)
}
export function loremIpsum(numWords: number): string {
let text = `Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.
Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi. Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat.
Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat. Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi.
Nam liber tempor cum soluta nobis eleifend option congue nihil imperdiet doming id quod mazim placerat facer possim assum. Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat.`
return text.split(" ").splice(0, numWords).join(" ");
}
export function randomDate(): Date {
let d = new Date()
d.setDate(d.getDate() - Math.random() * 20)
return d
}
+37 -38
View File
@@ -3,12 +3,12 @@
import { ref, onMounted, computed, watch } from 'vue'
import AppLayout from '@/layouts/AppLayout.vue'
import AppHeader from '@/components/AppHeader.vue'
import { Address } from '@/types'
import { Customer } from '@/types'
import { Address, Customer } from '@/types'
import { newCustomer } from '@/types/index.d'
import { bgColorForString } from '@/lib/utils'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { Button } from '@/components/ui/crm-button'
import { ButtonGroup } from '@/components/ui/button-group'
import { Delete, Globe, House, LayoutGrid, Mail, Phone, Plus, Rows3, Search, Smartphone } from "lucide-vue-next"
import Fuse from 'fuse.js';
@@ -19,6 +19,7 @@ import { TooltipArrow } from 'reka-ui'
import CustomerDialog from '@/components/CustomerDialog.vue'
import { toast } from 'vue-sonner'
import { SocialIcon } from '@/components/ui/social-icon'
import { Kbd, KbdGroup } from '@/components/ui/kbd'
interface Props {
customersData: Customer[];
@@ -56,6 +57,15 @@ const filteredCustomers = computed(() => {
return fuse.value.search(searchQuery.value).map(result => result.item);
})
const createCustomer = () => {
editCustomer(newCustomer())
}
const editCustomer = (customer: Customer) => {
// make a deep copy, so the changes in the dialog wont affect the data until saved
activeCustomer.value = JSON.parse(JSON.stringify(customer))
detailDialogOpen.value = true
}
const addressToClipbard = async function (companyName: string | null, address: Address | null, event: Event) {
event.stopPropagation();
@@ -76,11 +86,6 @@ const addressToClipbard = async function (companyName: string | null, address: A
}
}
}
const showDetail = (customer: Customer) => {
// make a deep copy, so the changes in the dialog wont affect the data until saved
activeCustomer.value = JSON.parse(JSON.stringify(customer))
detailDialogOpen.value = true
}
const mail = (email: string, event: Event) => {
event.stopPropagation();
@@ -122,14 +127,14 @@ const call = (number: string, event: Event) => {
<AppHeader>
<!-- View buttons -->
<template #left>
<ButtonGroup aria-label="Button group">
<!-- <ButtonGroup aria-label="Button group">
<Button variant="pressed" size="sm">
<LayoutGrid stroke-width="1.5" />
</Button>
<Button variant="outline" size="sm">
<Rows3 stroke-width="1.5" />
</Button>
</ButtonGroup>
</ButtonGroup> -->
</template>
@@ -149,10 +154,26 @@ const call = (number: string, event: Event) => {
<!-- New button -->
<template #right>
<Button size="sm" variant="action" @click="">
<Plus stroke-width="1.5" /> Neu
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Button size="sm" variant="action" @click="createCustomer">
<Plus />
Neu
</Button>
</TooltipTrigger>
<TooltipContent>
<span>Neuen Kunden anlegen</span>
<KbdGroup class="ml-2">
<Kbd class="visible-mac"></Kbd>
<Kbd class="visible-pc">Ctrl</Kbd>
<Kbd>N</Kbd>
</KbdGroup>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</template>
</AppHeader>
@@ -160,14 +181,14 @@ const call = (number: string, event: Event) => {
<Card v-for="customer in filteredCustomers" :key="customer.id"
class="relative mb-6 break-inside-avoid hover:border-slate-300 dark:hover:bg-neutral-800/90 dark:hover:border-neutral-600 overflow-clip"
@click="showDetail(customer)">
@click="editCustomer(customer)">
<CardHeader v-if="customer.logo" class="z-0">
<img :src="'storage/uploads/' + customer.logo" alt="Logo {{ customer.companyName }}"
class="max-h-8 max-w-[50%]">
</CardHeader>
<CardContent class="flex justify-between gap-4 flex-col sm:flex-row pr-4 z-0">
<CardContent class="flex justify-between gap-8 flex-col sm:flex-row pr-4 z-0">
<address class="not-italic">
<CardTitle>
{{ customer.companyName }}
@@ -224,7 +245,7 @@ const call = (number: string, event: Event) => {
<Tooltip v-for="contact in customer.contacts">
<TooltipTrigger>
<Avatar class="size-14 shadow -mr-2">
<Avatar class="size-14 border-2 border-background -mr-2">
<AvatarImage v-if="contact.avatar" :src="'/storage/uploads/' + contact.avatar"
loading="lazy" />
<AvatarFallback
@@ -279,26 +300,4 @@ const call = (number: string, event: Event) => {
</AppLayout>
</template>
<style>
/* Remove close X */
[data-slot=dialog-content] button.ring-offset-background {
/* display: none; */
border-radius: 100%;
position: absolute;
left: 1rem;
width: 1rem;
height: 1rem;
color: var(--color-destructive);
}
/* Backdrop */
[data-slot=dialog-overlay] {
backdrop-filter: blur(var(--blur-sm));
}
/* hover:not(:has(*:hover)) */
[data-slot="card"]:hover:has(a:hover),
[data-slot="card"]:hover:has(button:hover) {
background-color: var(--card);
}
</style>
<style></style>
-1
View File
@@ -11,7 +11,6 @@ import DocumentTable from '@/components/documents/DocumentTable.vue'
import { ChevronLeft, ChevronRight, Plus, Search, Delete } from "lucide-vue-next"
import Fuse from 'fuse.js';
import { Input } from '@/components/ui/input'
import { toast } from 'vue-sonner'
import SelectSeparator from '@/components/ui/select/SelectSeparator.vue'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { Kbd, KbdGroup } from '@/components/ui/kbd'