2025-10-20 08:57:51 +02:00
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
|
2025-10-23 08:27:54 +02:00
|
|
|
|
import { ref, onMounted, computed, useTemplateRef, watch } from 'vue'
|
2025-10-20 08:57:51 +02:00
|
|
|
|
import AppLayout from '@/layouts/AppLayout.vue'
|
|
|
|
|
|
import { customers } from '@/routes'
|
|
|
|
|
|
import { Address, type BreadcrumbItem } from '@/types'
|
|
|
|
|
|
import { Head } from '@inertiajs/vue3'
|
|
|
|
|
|
import api from '@/axios'
|
|
|
|
|
|
import { Customer, Contact } from '@/types'
|
|
|
|
|
|
import { randomInt, bgColorForString } from '@/lib/utils'
|
|
|
|
|
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
|
|
|
|
|
import { Input } from '@/components/ui/input'
|
|
|
|
|
|
import { Button } from '@/components/ui/button'
|
2025-10-21 19:53:07 +02:00
|
|
|
|
import { Copy, Delete, Edit, Phone, Plus, Search } from "lucide-vue-next"
|
2025-10-20 08:57:51 +02:00
|
|
|
|
import Fuse from 'fuse.js';
|
|
|
|
|
|
import { getInitials } from '@/composables/useInitials';
|
|
|
|
|
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from '@/components/ui/card'
|
|
|
|
|
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
2025-10-23 08:27:54 +02:00
|
|
|
|
import CustomerDialog from '@/components/CustomerDialog.vue'
|
2025-10-29 15:42:43 +01:00
|
|
|
|
import { toast } from 'vue-sonner'
|
|
|
|
|
|
import { AxiosError } from 'axios'
|
2025-10-20 08:57:51 +02:00
|
|
|
|
|
|
|
|
|
|
const breadcrumbs: BreadcrumbItem[] = [
|
|
|
|
|
|
{
|
|
|
|
|
|
title: 'Kunden',
|
|
|
|
|
|
href: customers().url,
|
|
|
|
|
|
},
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
const customersData = ref([] as Customer[])
|
|
|
|
|
|
const searchQuery = ref('')
|
|
|
|
|
|
const searchField = ref()
|
2025-10-21 19:53:07 +02:00
|
|
|
|
const activeCustomer = ref<Customer | null>(null)
|
|
|
|
|
|
const detailDialogOpen = ref(false)
|
2025-10-20 08:57:51 +02:00
|
|
|
|
|
|
|
|
|
|
onMounted(async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await api.get('/customers');
|
|
|
|
|
|
customersData.value = (response.data as Customer[]).toSorted((a, b) => a.companyName.localeCompare(b.companyName));;
|
|
|
|
|
|
searchField.value = document.getElementById('search')
|
|
|
|
|
|
searchField.value.focus()
|
|
|
|
|
|
|
2025-10-29 15:42:43 +01:00
|
|
|
|
} catch (error: AxiosError) {
|
|
|
|
|
|
toast.error(error.name, { description: error.message })
|
2025-10-20 08:57:51 +02:00
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2025-10-23 08:27:54 +02:00
|
|
|
|
watch(activeCustomer, () => {
|
|
|
|
|
|
console.log(activeCustomer.value?.companyName)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2025-10-20 08:57:51 +02:00
|
|
|
|
const fuse = computed(() => {
|
|
|
|
|
|
return new Fuse(customersData.value, {
|
|
|
|
|
|
keys: ['companyName', 'firstName', 'lastName', 'email', 'phone'],
|
|
|
|
|
|
});
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const filteredCustomers = computed(() => {
|
|
|
|
|
|
if (!searchQuery.value) {
|
|
|
|
|
|
return customersData.value;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return fuse.value.search(searchQuery.value).map(result => result.item);
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const addressToClipbard = async function (address: Address) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await navigator.clipboard.writeText(
|
|
|
|
|
|
address.lineOne + '\n' +
|
|
|
|
|
|
(address.lineTwo ? address.lineTwo + '\n' : '') +
|
|
|
|
|
|
address.postalCode + ' ' + address.city
|
|
|
|
|
|
)
|
2025-10-29 15:42:43 +01:00
|
|
|
|
toast('Adresse kopiert', { duration: 2000 })
|
|
|
|
|
|
|
|
|
|
|
|
} catch (notAllowedError: DOMException) {
|
|
|
|
|
|
if (notAllowedError instanceof Error) {
|
|
|
|
|
|
toast.error(notAllowedError.name, { description: notAllowedError.message })
|
|
|
|
|
|
}
|
2025-10-20 08:57:51 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-21 19:53:07 +02:00
|
|
|
|
const showDetail = (customer: Customer) => {
|
|
|
|
|
|
// make a deep copy, so the changes in the dialog won’t affect the data until saved
|
|
|
|
|
|
activeCustomer.value = JSON.parse(JSON.stringify(customer))
|
|
|
|
|
|
detailDialogOpen.value = true
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-20 08:57:51 +02:00
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
|
|
|
|
|
|
|
<Head title="Dashboard" />
|
|
|
|
|
|
|
|
|
|
|
|
<AppLayout :breadcrumbs="breadcrumbs">
|
|
|
|
|
|
<div class="flex h-full flex-1 flex-col gap-4 overflow-x-auto p-4 bg-slate-50 dark:bg-neutral-900/40">
|
|
|
|
|
|
|
|
|
|
|
|
<div class="flex">
|
|
|
|
|
|
<div class="relative w-full max-w-sm items-center mx-auto mb-6">
|
|
|
|
|
|
<Input ref="search-field" id="search" type="text" placeholder="Suchen" class="px-8"
|
|
|
|
|
|
v-model="searchQuery" autofocus />
|
|
|
|
|
|
<span class="absolute start-0 inset-y-0 flex items-center justify-center px-2">
|
|
|
|
|
|
<Search class="size-4 text-muted-foreground" :stroke-width="1.5" />
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span class="absolute end-0 inset-y-0 flex items-center justify-center px-0">
|
|
|
|
|
|
<Button :size="'sm'" :variant="'ghost'" @click="searchQuery = ''; searchField.focus()">
|
|
|
|
|
|
<Delete class="size-4 text-muted-foreground" :stroke-width="1.5" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-10-23 08:27:54 +02:00
|
|
|
|
<Button :size="'sm'" :variant="'action'" @click="">
|
2025-10-20 08:57:51 +02:00
|
|
|
|
<Plus /> Neu
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<div class="columns-xs gap-4">
|
|
|
|
|
|
|
|
|
|
|
|
<Card v-for="customer in filteredCustomers" :key="customer.id"
|
2025-10-21 19:53:07 +02:00
|
|
|
|
class="mb-4 shadow-xl break-inside-avoid hover:bg-accent" @click="showDetail(customer)">
|
2025-10-20 08:57:51 +02:00
|
|
|
|
<CardHeader>
|
|
|
|
|
|
<CardTitle>{{ customer.companyName }}</CardTitle>
|
|
|
|
|
|
<CardDescription>Kategorie / Umsatz</CardDescription>
|
|
|
|
|
|
</CardHeader>
|
|
|
|
|
|
<CardContent class="text-sm">
|
|
|
|
|
|
<address class="relative not-italic my-2">
|
|
|
|
|
|
{{ customer.billingAddress.lineOne }}<br />
|
|
|
|
|
|
{{ customer.billingAddress.lineTwo }}<br v-if="customer.billingAddress.lineTwo" />
|
|
|
|
|
|
{{ customer.billingAddress.postalCode }} {{ customer.billingAddress.city }}
|
|
|
|
|
|
<Button class="absolute end-0 top-0" size="sm" variant="ghost"
|
|
|
|
|
|
@click="addressToClipbard(customer.billingAddress)">
|
|
|
|
|
|
<Copy :stroke-width="1.5" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</address>
|
|
|
|
|
|
<a href="https://www.tooloop.de">www.tooloop.de</a><br />
|
|
|
|
|
|
<a href="tel://+4982165079983">+49 821 650799-83</a><br />
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
<CardFooter>
|
2025-10-29 14:21:30 +01:00
|
|
|
|
<TooltipProvider :delay-duration="0">
|
2025-10-20 08:57:51 +02:00
|
|
|
|
|
|
|
|
|
|
<Tooltip v-for="contact in customer.contacts">
|
|
|
|
|
|
<TooltipTrigger>
|
2025-10-21 19:53:07 +02:00
|
|
|
|
<Avatar class="-mr-2 border-2 size-14">
|
2025-10-23 08:27:54 +02:00
|
|
|
|
<AvatarImage :src="contact.avatar || ''" />
|
2025-10-20 08:57:51 +02:00
|
|
|
|
<AvatarFallback
|
|
|
|
|
|
:class="bgColorForString(getInitials(contact.firstName + ' ' + contact.lastName))">
|
|
|
|
|
|
{{ getInitials(contact.firstName + ' ' + contact.lastName) }}
|
|
|
|
|
|
</AvatarFallback>
|
|
|
|
|
|
</Avatar>
|
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
|
<TooltipContent>
|
|
|
|
|
|
<p>{{ contact.firstName + ' ' + contact.lastName }}</p>
|
|
|
|
|
|
</TooltipContent>
|
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
|
|
|
|
|
|
</TooltipProvider>
|
|
|
|
|
|
</CardFooter>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
|
|
</div>
|
2025-10-21 19:53:07 +02:00
|
|
|
|
|
|
|
|
|
|
<!-- Invoice detail dialog -->
|
2025-10-23 08:27:54 +02:00
|
|
|
|
<CustomerDialog :customerData="activeCustomer" v-model="detailDialogOpen" @save="" @delete="" />
|
2025-10-21 19:53:07 +02:00
|
|
|
|
|
|
|
|
|
|
|
2025-10-20 08:57:51 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
</AppLayout>
|
2025-10-23 08:27:54 +02:00
|
|
|
|
</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));
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|