This repository has been archived on 2025-12-04. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Caramel-CRM-Backup/resources/js/pages/Customers.vue
T
2025-11-21 08:39:34 +01:00

303 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, onMounted, computed, watch } from 'vue'
import AppLayout from '@/layouts/AppLayout.vue'
import AppHeader from '@/components/AppHeader.vue'
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/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';
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'
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[];
}
const props = defineProps<Props>();
const customersData = ref([] as Customer[])
const searchQuery = ref('')
const searchField = ref()
const activeCustomer = ref<Customer | null>(null)
const detailDialogOpen = ref(false)
onMounted(() => {
customersData.value = props.customersData.toSorted((a, b) => (a.companyName ?? '').localeCompare(b.companyName ?? ''));
searchField.value = document.getElementById('search')
searchField.value.focus()
})
watch(activeCustomer, () => {
})
const fuse = computed(() => {
return new Fuse(customersData.value, {
keys: ['companyName', 'contacts.firstName', 'contacts.lastName'],
threshold: 0.3
});
})
const filteredCustomers = computed(() => {
if (!searchQuery.value) {
return customersData.value;
}
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();
try {
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)
toast('Adresse kopiert', { duration: 2000 })
} catch (notAllowedError: DOMException) {
if (notAllowedError instanceof Error) {
toast.error(notAllowedError.name, { description: notAllowedError.message })
}
}
}
const mail = (email: string, event: Event) => {
event.stopPropagation();
window.open('mailto:' + email, '_self')
}
const browse = (url: string, event: Event) => {
event.stopPropagation();
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.'
});
}
}
const call = (number: string, event: Event) => {
event.stopPropagation();
window.open('tel:' + number, '_self')
}
</script>
<template>
<AppLayout title="Kunden">
<AppHeader>
<!-- View buttons -->
<template #left>
<!-- <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> -->
</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>
<!-- New button -->
<template #right>
<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>
<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"
@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-8 flex-col sm:flex-row pr-4 z-0">
<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">
<TooltipTrigger>
<Button variant="ghost" size="sm"
@click="(event: Event) => browse(customer.url as string, event)">
<Globe stroke-width="1.5" />
</Button>
</TooltipTrigger>
<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
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</CardContent>
<CardFooter v-if="customer.contacts.length > 0">
<TooltipProvider :delay-duration="0">
<Tooltip v-for="contact in customer.contacts">
<TooltipTrigger>
<Avatar class="size-14 border-2 border-background -mr-2">
<AvatarImage v-if="contact.avatar" :src="'/storage/uploads/' + contact.avatar"
loading="lazy" />
<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"
class="fill-popover drop-shadow-(--shadow-arrow) stroke-[0.5px] stroke-border -mt-[1px]" />
</TooltipContent>
</Tooltip>
</TooltipProvider>
</CardFooter>
</Card>
</div>
<!-- Invoice detail dialog -->
<CustomerDialog :customerData="activeCustomer" v-model="detailDialogOpen" @save="" @delete="" />
</AppLayout>
</template>
<style></style>