547 lines
25 KiB
Vue
547 lines
25 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, watch, useTemplateRef } from "vue"
|
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogClose } from "@/components/ui/dialog"
|
|
import { Button } from '@/components/ui/crm-button'
|
|
import { Textarea } from "@/components/ui/crm-textarea";
|
|
import { Input } from '@/components/ui/crm-input';
|
|
import { Customer, Note } from '@/types'
|
|
import { toast } from "vue-sonner"
|
|
import { alertStore } from "@/stores/alertStore"
|
|
import { Trash2, Loader2, Ellipsis, Check, Mail, Send, CornerDownLeft, Plus, PhoneCall } from "lucide-vue-next"
|
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'
|
|
import DialogCloseButton from "./DialogCloseButton/DialogCloseButton.vue";
|
|
import { TooltipProvider } from '@/components/ui/tooltip'
|
|
import { Separator } from '@/components/ui/separator'
|
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
|
import { bgColorForString, toLocalDate } from '@/lib/utils'
|
|
import { getInitials } from '@/composables/useInitials';
|
|
import { Kbd, KbdGroup } from '@/components/ui/kbd'
|
|
import axios, { AxiosError } from "axios";
|
|
import { usePage } from '@inertiajs/vue3';
|
|
|
|
|
|
|
|
|
|
const props = defineProps<{
|
|
modelValue: boolean
|
|
customerData: Customer | null,
|
|
}>()
|
|
const emit = defineEmits(['update:modelValue', 'update:open', 'save', 'cancel', 'delete'])
|
|
|
|
const page = usePage();
|
|
const auth = computed(() => page.props.auth);
|
|
const customer = ref<Customer | null>(props.customerData)
|
|
const isDirty = ref(false)
|
|
const isLoading = ref(false)
|
|
const isTakingNote = ref(false)
|
|
const notesLoading = ref(false)
|
|
const isSaving = ref(false)
|
|
const alert = alertStore()
|
|
const noteText = ref("")
|
|
const noteTextArea = useTemplateRef('note-textarea')
|
|
const isOpen = computed({
|
|
get: () => props.modelValue,
|
|
set: (value) => {
|
|
emit('update:modelValue', value)
|
|
}
|
|
})
|
|
|
|
const todos = ref([
|
|
{ text: 'Lorem ipsum', dueDate: '2025-11-25', done: false, type: 'phoneCall' },
|
|
{ text: 'Lorem ipsum', dueDate: '2025-11-25', done: false, type: 'mail' },
|
|
{ text: 'Lorem ipsum', dueDate: '2025-11-25', done: false, type: 'task' }
|
|
])
|
|
|
|
// watch for new external invoice data
|
|
watch(() => props.modelValue, (open) => {
|
|
// on open
|
|
if (open) {
|
|
|
|
// Set initial state for a newly opened document
|
|
isDirty.value = false
|
|
isLoading.value = true
|
|
|
|
customer.value = props.customerData
|
|
|
|
// load notes
|
|
if (customer.value && customer.value.id !== 0) {
|
|
notesLoading.value = true
|
|
|
|
try {
|
|
notesLoading.value = true
|
|
axios.get('/api/customers/' + customer.value.id + '/notes/').then(response => {
|
|
if (customer.value) {
|
|
customer.value.notes = response.data as Note<Customer>[]
|
|
}
|
|
})
|
|
} catch (error) {
|
|
toast.error('Fehler beim Laden der Notizen', error || String(error))
|
|
}
|
|
} else {
|
|
notesLoading.value = false
|
|
}
|
|
}
|
|
})
|
|
|
|
watch(customer, (newValue) => {
|
|
// isDirty.value = true
|
|
})
|
|
|
|
watch(isTakingNote, (newValue) => {
|
|
if (newValue) {
|
|
if (noteTextArea.value && noteTextArea.value.textareaRef) {
|
|
noteTextArea.value.textareaRef.focus();
|
|
}
|
|
}
|
|
})
|
|
|
|
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 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
|
|
}
|
|
}
|
|
|
|
const handleLogoUpload = (event: Event) => {
|
|
const target = event.target as HTMLInputElement;
|
|
if (target.files && target.files[0]) {
|
|
const file = target.files[0];
|
|
|
|
// Hier könntest du die Datei validieren (Größe, Typ, etc.)
|
|
if (file.size > 2 * 1024 * 1024) { // 2 MB
|
|
toast.warning('Die Datei ist zu groß', { description: 'Maximal 2 MB erlaubt' });
|
|
return;
|
|
}
|
|
|
|
// Hier könntest du die Datei hochladen und den Pfad speichern
|
|
// customer.value.logo = ... // Pfad zur hochgeladenen Datei
|
|
|
|
// Für die Vorschau:
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => {
|
|
if (customer.value) {
|
|
customer.value.logo = e.target?.result as string;
|
|
}
|
|
};
|
|
reader.readAsDataURL(file);
|
|
}
|
|
}
|
|
|
|
const saveNote = async () => {
|
|
if (!customer.value) return
|
|
if (!noteText.value.trim()) {
|
|
isTakingNote.value = false
|
|
return
|
|
}
|
|
|
|
try {
|
|
const response = await axios.post(`/api/customers/${customer.value.id}/notes`, {
|
|
text: noteText.value,
|
|
userId: auth.value.user.id
|
|
});
|
|
|
|
// Füge die neue Notiz zum Kunden hinzu
|
|
if (customer.value.notes) {
|
|
customer.value.notes.unshift(response.data);
|
|
} else {
|
|
customer.value.notes = [response.data];
|
|
}
|
|
|
|
// Leere das Notiz-Feld und schließe das Notiz-Formular
|
|
noteText.value = ""
|
|
isTakingNote.value = false
|
|
} catch (error) {
|
|
console.error("Fehler beim Speichern der Notiz:", error);
|
|
toast.error("Fehler beim Speichern der Notiz", {
|
|
description: (error as AxiosError).response?.data?.message || String(error)
|
|
});
|
|
}
|
|
}
|
|
|
|
const deleteNote = async (id: number) => {
|
|
|
|
alert.show(
|
|
"Möchtest Du diese Notiz wirklich löschen?", null,
|
|
{
|
|
actionText: "Löschen",
|
|
actionVariant: "destructive",
|
|
onAction: async () => {
|
|
try {
|
|
if (!customer.value?.notes) return
|
|
|
|
await axios.delete('/api/notes/' + id)
|
|
const index = customer.value.notes.findIndex(note => note.id === id)
|
|
if (index !== -1) {
|
|
customer.value.notes.splice(index, 1)
|
|
}
|
|
} catch (error) {
|
|
console.error("Fehler beim Löschen der Notiz:", error);
|
|
toast.error("Fehler beim Löschen der Notiz", {
|
|
description: (error as AxiosError).response?.data?.message || String(error)
|
|
});
|
|
}
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
</script>
|
|
|
|
<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),1344px)] 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! flex flex-row items-center gap-8">
|
|
|
|
<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" class="animate-spin" />
|
|
<Check v-else />
|
|
Speichern
|
|
</Button>
|
|
|
|
<!-- 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" />
|
|
</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 class="text-current" />
|
|
<span class="mr-2">Löschen</span>
|
|
</div>
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</TooltipProvider>
|
|
|
|
<DialogClose as-child>
|
|
<DialogCloseButton />
|
|
</DialogClose>
|
|
</div>
|
|
</DialogHeader>
|
|
|
|
|
|
<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">
|
|
|
|
|
|
<!-- Logo Upload -->
|
|
<div>
|
|
<div class="flex items-center gap-4">
|
|
<div v-if="customer.logo" class="w-24 h-24 border rounded-md overflow-hidden">
|
|
<img :src="'/storage/uploads/' + customer.logo" alt="Aktuelles Logo"
|
|
class="w-full h-full object-contain">
|
|
</div>
|
|
|
|
<!-- Logo hochladen -->
|
|
<div class="flex-1">
|
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Neues
|
|
Logo hochladen</label>
|
|
<input type="file" @change="handleLogoUpload" accept="image/*"
|
|
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">
|
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Max. 2MB, JPG, PNG oder GIF
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div class="space-y-4 w-full">
|
|
|
|
|
|
<!-- Kunden-Nummer -->
|
|
<div>
|
|
<label
|
|
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Kunden-Nummer</label>
|
|
<input type="text" v-model="customer.customerNr"
|
|
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>
|
|
|
|
<!-- USt-IdNr -->
|
|
<div>
|
|
<label
|
|
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">USt-IdNr</label>
|
|
<input type="text" v-model="customer.vatId"
|
|
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>
|
|
|
|
<!-- Website -->
|
|
<div>
|
|
<label
|
|
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Website</label>
|
|
<input type="url" v-model="customer.url"
|
|
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>
|
|
|
|
<!-- Telefon -->
|
|
<div>
|
|
<label
|
|
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Telefon</label>
|
|
<input type="tel" v-model="customer.phone"
|
|
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>
|
|
|
|
<!-- Rechnungsadresse -->
|
|
<div class="mt-6">
|
|
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-3">Rechnungsadresse</h3>
|
|
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label
|
|
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Straße</label>
|
|
<input type="text" v-model="customer.billingAddress.lineOne"
|
|
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>
|
|
<label
|
|
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Zusatzzeile</label>
|
|
<input type="text" v-model="customer.billingAddress.lineTwo"
|
|
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>
|
|
<label
|
|
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">PLZ</label>
|
|
<input type="text" v-model="customer.billingAddress.postalCode"
|
|
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>
|
|
<label
|
|
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Stadt</label>
|
|
<input type="text" v-model="customer.billingAddress.city"
|
|
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>
|
|
<label
|
|
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Land</label>
|
|
<input type="text" v-model="customer.billingAddress.country"
|
|
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>
|
|
</div>
|
|
|
|
<!-- Zahlungsbedingungen -->
|
|
<div class="mt-6">
|
|
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-3">Zahlungsbedingungen
|
|
</h3>
|
|
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label
|
|
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Bezeichnung</label>
|
|
<input type="text" v-model="customer.paymentTerms.name"
|
|
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>
|
|
<label
|
|
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Beschreibung</label>
|
|
<input type="text" v-model="customer.paymentTerms.description"
|
|
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>
|
|
<label
|
|
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Tage</label>
|
|
<input type="number" v-model="customer.paymentTerms.days"
|
|
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="flex items-center mt-5">
|
|
<input type="checkbox" v-model="customer.paymentTerms.isFixed"
|
|
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded dark:bg-neutral-800 dark:border-neutral-700">
|
|
<label class="ml-2 block text-sm text-gray-700 dark:text-gray-300">Fix</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Status -->
|
|
<div class="mt-6">
|
|
<label
|
|
class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Status</label>
|
|
<select v-model="customer.status"
|
|
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">
|
|
<option value="active">Aktiv</option>
|
|
<option value="inactive">Inaktiv</option>
|
|
<option value="prospect">Interessent</option>
|
|
</select>
|
|
</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 />
|
|
</Button>
|
|
</div>
|
|
|
|
<ul class="flex flex-col">
|
|
<li v-for="todo in todos">
|
|
<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 v-model="todo.text"
|
|
class="my-0 px-0 text-base! h-6 border-0 outline-0 shadow-none" />
|
|
<PhoneCall v-if="todo.type === 'phoneCall'" :size="16"
|
|
class="text-muted-foreground" />
|
|
<Check v-else-if="todo.type === 'task'" :size="16"
|
|
class="text-muted-foreground" />
|
|
<Mail v-else-if="todo.type === 'mail'" :size="16"
|
|
class="text-muted-foreground" />
|
|
</div>
|
|
|
|
<div class="text-sm text-muted-foreground">{{ toLocalDate(todo.dueDate) }}</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 = true">
|
|
<Plus />
|
|
</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 ref="note-textarea" v-if="customer" v-model="noteText"
|
|
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 class="h-3 w-3" />
|
|
</Kbd>
|
|
</KbdGroup>
|
|
<Button class="w-20" size="sm" variant="outline" @click="saveNote">
|
|
<Send />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<!-- Notes -->
|
|
<div class="overflow-y-auto flex flex-col gap-6">
|
|
<article v-for="note in customer?.notes">
|
|
<div class="text-muted-foreground text-sm font-medium flex gap-3 items-center mb-1">
|
|
<Avatar class="size-6 text-xs">
|
|
<AvatarImage :src="(note.user.avatar || '')" loading="lazy" />
|
|
<AvatarFallback :class="bgColorForString(getInitials(note.user.name))">
|
|
{{ getInitials(note.user.name) }}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<span>{{ toLocalDate(note.createdAt) }}</span>
|
|
<div class="grow-1"></div>
|
|
<Button variant="ghost" size="sm" @click="deleteNote(note.id)">
|
|
<Trash2 />
|
|
</Button>
|
|
</div>
|
|
<div class="text-sm whitespace-pre-wrap">{{ note.text }}</div>
|
|
</article>
|
|
</div>
|
|
|
|
|
|
</aside>
|
|
</div>
|
|
|
|
</DialogContent>
|
|
</Dialog>
|
|
</template>
|
|
|
|
<style></style> |