2025-10-20 08:57:51 +02:00
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
|
2025-11-14 17:45:57 +01:00
|
|
|
|
import { ref, onMounted, computed, watch } from 'vue'
|
2025-10-20 08:57:51 +02:00
|
|
|
|
import AppLayout from '@/layouts/AppLayout.vue'
|
2025-11-14 17:45:57 +01:00
|
|
|
|
import AppHeader from '@/components/AppHeader.vue'
|
2025-11-21 08:39:34 +01:00
|
|
|
|
import { Address, Customer } from '@/types'
|
|
|
|
|
|
import { newCustomer } from '@/types/index.d'
|
2025-11-14 17:45:57 +01:00
|
|
|
|
import { bgColorForString } from '@/lib/utils'
|
2025-10-20 08:57:51 +02:00
|
|
|
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
2025-11-21 13:21:59 +01:00
|
|
|
|
import { Input } from '@/components/ui/crm-input'
|
2025-11-21 08:39:34 +01:00
|
|
|
|
import { Button } from '@/components/ui/crm-button'
|
2025-11-14 17:45:57 +01:00
|
|
|
|
import { ButtonGroup } from '@/components/ui/button-group'
|
|
|
|
|
|
import { Delete, Globe, House, LayoutGrid, Mail, Phone, Plus, Rows3, Search, Smartphone } 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-11-10 16:09:53 +01:00
|
|
|
|
import { TooltipArrow } from 'reka-ui'
|
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'
|
2025-11-04 17:45:48 +01:00
|
|
|
|
import { SocialIcon } from '@/components/ui/social-icon'
|
2025-11-21 08:39:34 +01:00
|
|
|
|
import { Kbd, KbdGroup } from '@/components/ui/kbd'
|
2025-11-18 10:27:49 +01:00
|
|
|
|
|
|
|
|
|
|
interface Props {
|
|
|
|
|
|
customersData: Customer[];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const props = defineProps<Props>();
|
2025-10-20 08:57:51 +02:00
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
2025-11-18 10:27:49 +01:00
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
customersData.value = props.customersData.toSorted((a, b) => (a.companyName ?? '').localeCompare(b.companyName ?? ''));
|
|
|
|
|
|
searchField.value = document.getElementById('search')
|
|
|
|
|
|
searchField.value.focus()
|
2025-10-20 08:57:51 +02:00
|
|
|
|
})
|
|
|
|
|
|
|
2025-10-23 08:27:54 +02:00
|
|
|
|
watch(activeCustomer, () => {
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2025-10-20 08:57:51 +02:00
|
|
|
|
const fuse = computed(() => {
|
|
|
|
|
|
return new Fuse(customersData.value, {
|
2025-11-04 13:51:31 +01:00
|
|
|
|
keys: ['companyName', 'contacts.firstName', 'contacts.lastName'],
|
|
|
|
|
|
threshold: 0.3
|
2025-10-20 08:57:51 +02:00
|
|
|
|
});
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const filteredCustomers = computed(() => {
|
|
|
|
|
|
if (!searchQuery.value) {
|
|
|
|
|
|
return customersData.value;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return fuse.value.search(searchQuery.value).map(result => result.item);
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2025-11-21 08:39:34 +01:00
|
|
|
|
const createCustomer = () => {
|
|
|
|
|
|
editCustomer(newCustomer())
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const editCustomer = (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
|
|
|
|
|
2025-11-04 13:51:31 +01:00
|
|
|
|
const addressToClipbard = async function (companyName: string | null, address: Address | null, event: Event) {
|
|
|
|
|
|
event.stopPropagation();
|
2025-10-20 08:57:51 +02:00
|
|
|
|
try {
|
2025-11-04 13:51:31 +01:00
|
|
|
|
let copy = '';
|
|
|
|
|
|
if (companyName) copy += companyName + '\n'
|
|
|
|
|
|
if (address) {
|
|
|
|
|
|
copy +=
|
|
|
|
|
|
address.lineOne + '\n' +
|
|
|
|
|
|
(address.lineTwo ? address.lineTwo + '\n' : '') +
|
|
|
|
|
|
address.postalCode + ' ' + address.city
|
|
|
|
|
|
}
|
|
|
|
|
|
await navigator.clipboard.writeText(copy)
|
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
|
|
|
|
|
2025-11-04 15:17:23 +01:00
|
|
|
|
const mail = (email: string, event: Event) => {
|
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
window.open('mailto:' + email, '_self')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-04 13:51:31 +01:00
|
|
|
|
const browse = (url: string, event: Event) => {
|
|
|
|
|
|
event.stopPropagation();
|
2025-11-04 15:17:23 +01:00
|
|
|
|
|
|
|
|
|
|
if (!/^https?:\/\//i.test(url)) {
|
|
|
|
|
|
url = 'https://' + url;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
const parsedUrl = new URL(url);
|
|
|
|
|
|
if (!parsedUrl.hostname || parsedUrl.hostname === 'sers') {
|
|
|
|
|
|
throw new Error('Ungültiger Hostname');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
window.open(url, '_blank');
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
toast.error('Ungültige URL', {
|
|
|
|
|
|
description: 'Die eingegebene URL ist nicht gültig.'
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2025-11-04 13:51:31 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const call = (number: string, event: Event) => {
|
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
|
window.open('tel:' + number, '_self')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-20 08:57:51 +02:00
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
|
|
2025-11-14 17:45:57 +01:00
|
|
|
|
<AppLayout title="Kunden">
|
2025-10-20 08:57:51 +02:00
|
|
|
|
|
2025-11-14 17:45:57 +01:00
|
|
|
|
<AppHeader>
|
|
|
|
|
|
<!-- View buttons -->
|
|
|
|
|
|
<template #left>
|
2025-11-21 08:39:34 +01:00
|
|
|
|
<!-- <ButtonGroup aria-label="Button group">
|
2025-11-04 13:51:31 +01:00
|
|
|
|
<Button variant="pressed" size="sm">
|
|
|
|
|
|
<LayoutGrid stroke-width="1.5" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button variant="outline" size="sm">
|
|
|
|
|
|
<Rows3 stroke-width="1.5" />
|
|
|
|
|
|
</Button>
|
2025-11-21 08:39:34 +01:00
|
|
|
|
</ButtonGroup> -->
|
2025-11-14 17:45:57 +01:00
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Search field -->
|
|
|
|
|
|
<template #middle>
|
|
|
|
|
|
<Input ref="search-field" id="search" type="text" placeholder="Filtern" class="px-8 bg-background"
|
|
|
|
|
|
v-model="searchQuery" />
|
|
|
|
|
|
<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 mr-1">
|
|
|
|
|
|
<Button :size="'sm'" :variant="'ghost'" @click="searchQuery = ''; searchField.focus()">
|
|
|
|
|
|
<Delete class="size-4 text-muted-foreground" :stroke-width="1.5" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</template>
|
2025-11-04 13:51:31 +01:00
|
|
|
|
|
2025-11-14 17:45:57 +01:00
|
|
|
|
<!-- New button -->
|
|
|
|
|
|
<template #right>
|
2025-11-21 08:39:34 +01:00
|
|
|
|
<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>
|
2025-11-14 17:45:57 +01:00
|
|
|
|
</template>
|
2025-11-21 08:39:34 +01:00
|
|
|
|
|
2025-11-14 17:45:57 +01:00
|
|
|
|
</AppHeader>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
<div class="columns-xs gap-6">
|
|
|
|
|
|
|
|
|
|
|
|
<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"
|
2025-11-21 08:39:34 +01:00
|
|
|
|
@click="editCustomer(customer)">
|
2025-11-14 17:45:57 +01:00
|
|
|
|
|
|
|
|
|
|
<CardHeader v-if="customer.logo" class="z-0">
|
2025-11-28 09:17:16 +01:00
|
|
|
|
<img :src="'storage/uploads/customers/' + customer.logo" alt="Logo {{ customer.companyName }}"
|
|
|
|
|
|
class="max-h-8 max-w-[50%]" loading="lazy">
|
2025-11-14 17:45:57 +01:00
|
|
|
|
</CardHeader>
|
|
|
|
|
|
|
2025-11-21 08:39:34 +01:00
|
|
|
|
<CardContent class="flex justify-between gap-8 flex-col sm:flex-row pr-4 z-0">
|
2025-11-14 17:45:57 +01:00
|
|
|
|
<address class="not-italic">
|
|
|
|
|
|
<CardTitle>
|
|
|
|
|
|
{{ customer.companyName }}
|
|
|
|
|
|
<!-- <Badge variant="secondary">Badge</Badge> -->
|
|
|
|
|
|
</CardTitle>
|
|
|
|
|
|
<CardDescription class="mt-2">
|
|
|
|
|
|
{{ customer.billingAddress?.lineOne }}<br />
|
|
|
|
|
|
{{ customer.billingAddress?.lineTwo }}<br v-if="customer.billingAddress?.lineTwo" />
|
|
|
|
|
|
{{ customer.billingAddress?.postalCode }} {{ customer.billingAddress?.city }}
|
|
|
|
|
|
</CardDescription>
|
|
|
|
|
|
</address>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="flex items-start">
|
|
|
|
|
|
<TooltipProvider>
|
|
|
|
|
|
<Tooltip v-if="customer.url">
|
2025-10-20 08:57:51 +02:00
|
|
|
|
<TooltipTrigger>
|
2025-11-14 17:45:57 +01:00
|
|
|
|
<Button variant="ghost" size="sm"
|
|
|
|
|
|
@click="(event: Event) => browse(customer.url as string, event)">
|
|
|
|
|
|
<Globe stroke-width="1.5" />
|
|
|
|
|
|
</Button>
|
2025-10-20 08:57:51 +02:00
|
|
|
|
</TooltipTrigger>
|
2025-11-14 17:45:57 +01:00
|
|
|
|
<TooltipContent>
|
|
|
|
|
|
{{ customer.url }} öffnen
|
|
|
|
|
|
</TooltipContent>
|
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
<Tooltip v-if="customer.phone">
|
|
|
|
|
|
<TooltipTrigger>
|
|
|
|
|
|
<Button variant="ghost" size="sm"
|
|
|
|
|
|
@click="(event: Event) => call(customer.phone as string, event)">
|
|
|
|
|
|
<Phone stroke-width="1.5" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
|
<TooltipContent>
|
|
|
|
|
|
{{ customer.phone }} anrufen
|
|
|
|
|
|
</TooltipContent>
|
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
<Tooltip v-if="customer.billingAddress">
|
|
|
|
|
|
<TooltipTrigger>
|
|
|
|
|
|
<Button variant="ghost" size="sm"
|
|
|
|
|
|
@click="(event: Event) => addressToClipbard(customer.companyName, customer.billingAddress as Address | null, event)">
|
|
|
|
|
|
<House stroke-width="1.5" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
|
<TooltipContent>
|
|
|
|
|
|
Adresse in Zwischenablage kopieren
|
2025-10-20 08:57:51 +02:00
|
|
|
|
</TooltipContent>
|
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
</TooltipProvider>
|
2025-11-14 17:45:57 +01:00
|
|
|
|
</div>
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
|
|
|
|
|
|
<CardFooter v-if="customer.contacts.length > 0">
|
|
|
|
|
|
<TooltipProvider :delay-duration="0">
|
|
|
|
|
|
|
|
|
|
|
|
<Tooltip v-for="contact in customer.contacts">
|
|
|
|
|
|
<TooltipTrigger>
|
2025-11-21 08:39:34 +01:00
|
|
|
|
<Avatar class="size-14 border-2 border-background -mr-2">
|
2025-11-28 09:17:16 +01:00
|
|
|
|
<AvatarImage v-if="contact.avatar" :src="'/storage/uploads/contacts/' + contact.avatar"
|
|
|
|
|
|
loading="lazy" class="object-cover" />
|
2025-11-14 17:45:57 +01:00
|
|
|
|
<AvatarFallback
|
|
|
|
|
|
:class="bgColorForString(getInitials(contact.firstName + ' ' + contact.lastName))">
|
|
|
|
|
|
{{ getInitials(contact.firstName + ' ' + contact.lastName) }}
|
|
|
|
|
|
</AvatarFallback>
|
|
|
|
|
|
</Avatar>
|
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
|
<TooltipContent class="p-4 -mr-2" :side-offset="-1">
|
|
|
|
|
|
<p class="font-bold">
|
|
|
|
|
|
<span v-if="contact.academicTitle">{{ contact.academicTitle }}</span>
|
|
|
|
|
|
<span>{{ contact.firstName + ' ' + contact.lastName }}</span>
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<p v-if="contact.jobTitle" class="text-muted-foreground">{{ contact.jobTitle }}</p>
|
|
|
|
|
|
|
|
|
|
|
|
<ButtonGroup class="mt-4">
|
|
|
|
|
|
<!-- E-mail -->
|
|
|
|
|
|
<Button size="sm" v-if="contact.email"
|
|
|
|
|
|
@click="(event: Event) => mail(contact.email as string, event)">
|
|
|
|
|
|
<Mail stroke-width="1.5" @click="" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<!-- Phone -->
|
|
|
|
|
|
<Button size="sm" v-if="contact.phone"
|
|
|
|
|
|
@click="(event: Event) => call(contact.phone as string, event)">
|
|
|
|
|
|
<Phone stroke-width="1.5" @click="" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<!-- Mobile phone -->
|
|
|
|
|
|
<Button size="sm" v-if="contact.mobilePhone"
|
|
|
|
|
|
@click="(event: Event) => call(contact.mobilePhone as string, event)">
|
|
|
|
|
|
<Smartphone stroke-width="1.5" @click="" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<!-- Online accounts -->
|
|
|
|
|
|
<Button size="sm" v-for="account in contact.onlineAccounts"
|
|
|
|
|
|
@click="(event: Event) => browse(account.url as string, event)">
|
|
|
|
|
|
<SocialIcon :variant="account.platform" />
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</ButtonGroup>
|
|
|
|
|
|
<TooltipArrow :height="8" :width="16"
|
2025-12-08 13:23:33 +01:00
|
|
|
|
class="fill-popover drop-shadow-(--shadow-arrow) stroke-[0.5px] stroke-border -mt-px" />
|
2025-11-14 17:45:57 +01:00
|
|
|
|
</TooltipContent>
|
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
|
|
|
|
|
|
</TooltipProvider>
|
|
|
|
|
|
</CardFooter>
|
|
|
|
|
|
</Card>
|
2025-10-20 08:57:51 +02:00
|
|
|
|
|
2025-11-14 17:45:57 +01:00
|
|
|
|
</div>
|
2025-10-21 19:53:07 +02:00
|
|
|
|
|
2025-11-14 17:45:57 +01:00
|
|
|
|
<!-- Invoice detail dialog -->
|
|
|
|
|
|
<CustomerDialog :customerData="activeCustomer" v-model="detailDialogOpen" @save="" @delete="" />
|
2025-10-21 19:53:07 +02:00
|
|
|
|
|
2025-10-20 08:57:51 +02:00
|
|
|
|
</AppLayout>
|
2025-10-23 08:27:54 +02:00
|
|
|
|
</template>
|
|
|
|
|
|
|
2025-11-21 08:39:34 +01:00
|
|
|
|
<style></style>
|