Files
Caramel-CRM/resources/js/pages/Customers.vue
T

360 lines
15 KiB
Vue
Raw Normal View History

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'
2026-02-17 10:35:03 +01:00
import { hotkey, 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'
2026-01-22 11:36:00 +01:00
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogTitle, DialogClose } from "@/components/ui/dialog"
import DialogCloseButton from "@/components/DialogCloseButton/DialogCloseButton.vue"
2025-10-20 08:57:51 +02:00
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
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)
2026-01-22 11:36:00 +01:00
const phoneNumber = ref('')
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()
2026-02-17 10:35:03 +01:00
// Register hotkeys
hotkey('mod+f', () => { 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 wont 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
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();
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
}
2026-01-22 11:36:00 +01:00
const showPhoneNumber = (number: string, event: Event | null) => {
if (event) {
event.preventDefault()
event.stopPropagation();
event.returnValue = true
}
phoneNumber.value = number
}
const closePhoneNumberDisplay = (event: Event | null) => {
if (event) {
event.preventDefault()
event.stopPropagation();
event.returnValue = true
}
phoneNumber.value = ''
}
2025-11-04 13:51:31 +01:00
const call = (number: string, event: Event) => {
2026-01-22 11:36:00 +01:00
if (event) {
event.preventDefault()
event.stopPropagation();
event.returnValue = true
}
2025-11-04 13:51:31 +01:00
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">
2026-02-17 10:35:03 +01:00
<LayoutGrid />
2025-11-04 13:51:31 +01:00
</Button>
<Button variant="outline" size="sm">
2026-02-17 10:35:03 +01:00
<Rows3 />
2025-11-04 13:51:31 +01:00
</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">
2026-02-17 10:35:03 +01:00
<Search class="size-4 text-muted-foreground" />
2025-11-14 17:45:57 +01:00
</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()">
2026-02-17 10:35:03 +01:00
<Delete class="size-4 text-muted-foreground" />
2025-11-14 17:45:57 +01:00
</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>
2026-02-17 10:35:03 +01:00
<Button @click="createCustomer">
2025-11-21 08:39:34 +01:00
<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"
2026-02-17 10:35:03 +01:00
class="relative mb-6 break-inside-avoid hover:ring-ring/75 hover:ring-[3px] 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 }}
</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>
2026-01-22 11:36:00 +01:00
<Button variant="ghost" size="sm" @click="browse(customer.url as string, $event)">
2026-02-17 10:35:03 +01:00
<Globe />
2025-11-14 17:45:57 +01:00
</Button>
2025-10-20 08:57:51 +02:00
</TooltipTrigger>
2026-01-22 11:36:00 +01:00
<TooltipContent>{{ customer.url }}</TooltipContent>
2025-11-14 17:45:57 +01:00
</Tooltip>
<Tooltip v-if="customer.phone">
<TooltipTrigger>
<Button variant="ghost" size="sm"
2026-01-22 11:36:00 +01:00
@click="showPhoneNumber(customer.phone as string, $event)">
2026-02-17 10:35:03 +01:00
<Phone />
2025-11-14 17:45:57 +01:00
</Button>
</TooltipTrigger>
2026-01-22 11:36:00 +01:00
<TooltipContent>{{ customer.phone }}</TooltipContent>
2025-11-14 17:45:57 +01:00
</Tooltip>
<Tooltip v-if="customer.billingAddress">
<TooltipTrigger>
<Button variant="ghost" size="sm"
2026-01-22 11:36:00 +01:00
@click="addressToClipbard(customer.companyName, customer.billingAddress as Address | null, $event)">
2026-02-17 10:35:03 +01:00
<House />
2025-11-14 17:45:57 +01:00
</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">
2026-01-22 11:36:00 +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>
2026-02-17 10:35:03 +01:00
<TooltipContent class="p-4 -mr-2 text-base" :side-offset="-1">
2025-11-14 17:45:57 +01:00
<p class="font-bold">
2026-02-17 10:35:03 +01:00
<span v-if="contact.academicTitle">{{ contact.academicTitle }}&nbsp;</span>
2025-11-14 17:45:57 +01:00
<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"
2026-01-22 11:36:00 +01:00
@click="mail(contact.email as string, $event)">
2026-02-17 10:35:03 +01:00
<Mail @click="" />
2025-11-14 17:45:57 +01:00
</Button>
<!-- Phone -->
<Button size="sm" v-if="contact.phone"
2026-01-22 11:36:00 +01:00
@click="showPhoneNumber(contact.phone as string, $event)">
2026-02-17 10:35:03 +01:00
<Phone @click="" />
2025-11-14 17:45:57 +01:00
</Button>
<!-- Mobile phone -->
<Button size="sm" v-if="contact.mobilePhone"
2026-01-22 11:36:00 +01:00
@click="showPhoneNumber(contact.mobilePhone as string, $event)">
2026-02-17 10:35:03 +01:00
<Smartphone @click="" />
2025-11-14 17:45:57 +01:00
</Button>
<!-- Online accounts -->
<Button size="sm" v-for="account in contact.onlineAccounts"
2026-01-22 11:36:00 +01:00
@click="browse(account.url as string, $event)">
2025-11-14 17:45:57 +01:00
<SocialIcon :variant="account.platform" />
</Button>
</ButtonGroup>
<TooltipArrow :height="8" :width="16"
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
2026-01-22 11:36:00 +01:00
<!-- Phone number display -->
<Dialog :open="phoneNumber !== ''">
2026-02-17 10:35:03 +01:00
<!-- sm:p-8 -->
<DialogContent class="w-fit sm:max-w-auto" @escapeKeyDown="closePhoneNumberDisplay"
@interactOutside="closePhoneNumberDisplay">
<DialogTitle class="
md:p-9 lg:ps-12 xl:p-14
text-md sm:text-2xl md:text-3xl lg:text-6xl xl:text-8xl
transition-all whitespace-nowrap proportional-nums lg:tracking-wide font-medium">
2026-01-22 11:36:00 +01:00
<h1>{{ phoneNumber }}</h1>
</DialogTitle>
<DialogDescription class="screen-reader-only hidden">Anrufen</DialogDescription>
<DialogFooter>
<DialogClose as-child>
<Button @click="closePhoneNumberDisplay">
Schließen
</Button>
<Button @click="call(phoneNumber, $event)" variant="action">
2026-02-17 10:35:03 +01:00
<Phone />
2026-01-22 11:36:00 +01:00
Anrufen
</Button>
</DialogClose>
</DialogFooter>
<DialogClose as-child>
<DialogCloseButton @click="closePhoneNumberDisplay" />
</DialogClose>
</DialogContent>
</Dialog>
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>