Add Notes model, controller and database table. Implement it in Customer Dialog, #6

This commit is contained in:
2025-11-21 13:23:13 +01:00
parent c152842e87
commit 8056c12f6d
11 changed files with 396 additions and 47 deletions
+135 -37
View File
@@ -1,41 +1,44 @@
<script setup lang="ts">
import { ref, computed, watch } from "vue"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter, DialogClose } from "@/components/ui/dialog"
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/textarea";
import { Input } from '@/components/ui/input';
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput, InputGroupText, InputGroupTextarea } from '@/components/ui/crm-input-group'
import { Customer } from '@/types'
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, ImportIcon, Mail, Notebook, Send, CornerDownLeft, Plus, CirclePlus, Phone, PhoneCall } from "lucide-vue-next"
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 { Table, TableCell, TableFooter, TableHead, TableHeader, TableRow, } from '@/components/ui/table';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { TooltipProvider } from '@/components/ui/tooltip'
import { Separator } from '@/components/ui/separator'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { bgColorForString, toLocalDate, randomDate, loremIpsum } from '@/lib/utils'
import { bgColorForString, toLocalDate } from '@/lib/utils'
import { getInitials } from '@/composables/useInitials';
import GrowingTextarea from "./ui/growing-textarea/GrowingTextarea.vue";
import { Kbd, KbdGroup } from '@/components/ui/kbd'
import Checkbox from "./ui/checkbox/Checkbox.vue";
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 isDirty = ref(false)
const isLoading = ref(false)
const isTakingNote = ref(false)
const notesLoading = ref(false)
const isSaving = ref(false)
const alert = alertStore()
const isTakingNote = ref(false)
const noteText = ref("")
const noteTextArea = useTemplateRef('note-textarea')
const isOpen = computed({
get: () => props.modelValue,
set: (value) => {
@@ -43,6 +46,12 @@ const isOpen = computed({
}
})
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
@@ -53,6 +62,24 @@ watch(() => props.modelValue, (open) => {
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
}
}
})
@@ -60,6 +87,14 @@ 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
@@ -126,7 +161,65 @@ const handleLogoUpload = (event: Event) => {
};
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>
@@ -361,7 +454,7 @@ const handleLogoUpload = (event: Event) => {
</div>
<ul class="flex flex-col">
<li v-for="i in 3">
<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
@@ -373,14 +466,17 @@ const handleLogoUpload = (event: Event) => {
</div>
<div class="flex items-center gap-2">
<Input :value="loremIpsum(Math.random() * 5 + 1)"
class="my-0 px-0 text-base! h-6 border-0 outline-0 shadow-none" />
<!-- <PhoneCall stroke-width="1.5" :size="16" class="text-muted-foreground" /> -->
<!-- <Check stroke-width="1.5" :size="16" class="text-muted-foreground" /> -->
<Mail stroke-width="1.5" :size="16" class="text-muted-foreground" />
<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'" stroke-width="1.5" :size="16"
class="text-muted-foreground" />
<Check v-else-if="todo.type === 'task'" stroke-width="1.5" :size="16"
class="text-muted-foreground" />
<Mail v-else-if="todo.type === 'mail'" stroke-width="1.5" :size="16"
class="text-muted-foreground" />
</div>
<div class="text-sm text-muted-foreground">{{ toLocalDate(randomDate()) }}</div>
<div class="text-sm text-muted-foreground">{{ toLocalDate(todo.dueDate) }}</div>
</div>
</li>
@@ -393,7 +489,7 @@ const handleLogoUpload = (event: Event) => {
<div>
<div class="flex justify-between items-center mb-6">
<h2 class="font-bold">Notizen</h2>
<Button variant="ghost" @click="isTakingNote = !isTakingNote">
<Button variant="ghost" @click="isTakingNote = true">
<Plus stroke-width="1.5" />
</Button>
</div>
@@ -401,8 +497,8 @@ const handleLogoUpload = (event: Event) => {
<div :class="{ 'h-0': !isTakingNote }"
class="my-6 flex flex-col items-end gap-2 overflow-hidden transition-[height] h-[calc-size(auto)]">
<Textarea v-if="customer" v-model="customer.notes" class="bg-background resize-none"
placeholder="Neue Notiz"></Textarea>
<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">
@@ -412,7 +508,7 @@ const handleLogoUpload = (event: Event) => {
<CornerDownLeft stroke-width="1.5" class="h-3 w-3" />
</Kbd>
</KbdGroup>
<Button class="w-20" size="sm" variant="outline">
<Button class="w-20" size="sm" variant="outline" @click="saveNote">
<Send stroke-width="1.5" />
</Button>
</div>
@@ -422,19 +518,21 @@ const handleLogoUpload = (event: Event) => {
<!-- Notes -->
<div class="overflow-y-auto flex flex-col gap-6">
<article v-for="i in 5">
<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="'https://i.pravatar.cc/100?img=' + i" loading="lazy" />
<AvatarFallback :class="bgColorForString(getInitials('Daniel Stock'))">
{{ getInitials('Daniel Stock') }}
<AvatarImage :src="(note.user.avatar || '')" loading="lazy" />
<AvatarFallback :class="bgColorForString(getInitials(note.user.name))">
{{ getInitials(note.user.name) }}
</AvatarFallback>
</Avatar>
<span>{{ toLocalDate(randomDate()) }}</span>
</div>
<div class="text-sm">
{{ loremIpsum(Math.random() * 30) }}
<span>{{ toLocalDate(note.createdAt) }}</span>
<div class="grow-1"></div>
<Button variant="ghost" size="sm" @click="deleteNote(note.id)">
<Trash2 stroke-width="1.5" />
</Button>
</div>
<div class="text-sm whitespace-pre-wrap">{{ note.text }}</div>
</article>
</div>