Connect CalDAV todos to DB-Items, finish todo component and add it to pipeline items

This commit is contained in:
2026-02-24 16:15:21 +01:00
parent 7e2094847f
commit 823cd6391d
27 changed files with 605 additions and 205 deletions
@@ -79,7 +79,7 @@ const cancel = (event: Event | null) => {
</DialogHeader>
<div class="flex flex-row">
<div class="p-4 md:p-6 lg:p-12 pt-0! grow">
<div class="p-4 md:p-6 lg:p-12 pt-0! grow overflow-y-auto">
<slot name="content"></slot>
</div>
<aside class="w-120 p-4 md:p-6 lg:p-12 pt-0! flex flex-col gap-4" v-if="$slots.sidebar">
+25 -93
View File
@@ -1,18 +1,16 @@
<script setup lang="ts">
import { Editor, EditorContent, } from '@tiptap/vue-3'
import { onBeforeUnmount, onMounted, ref, watch, h, render } from 'vue';
import { Editor, EditorContent } from '@tiptap/vue-3'
import TextEditorMenu from './TextEditorMenu.vue';
import StarterKit from '@tiptap/starter-kit'
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { ButtonGroup, ButtonGroupSeparator } from './ui/button-group';
import { Button } from './ui/crm-button';
import { Bold, Code2, Heading, Heading1, Heading2, Heading3, Heading4, Heading5, Heading6, Italic, List, ListOrdered, Pilcrow, Redo2, Strikethrough, Undo2 } from 'lucide-vue-next';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator } from '@/components/ui/dropdown-menu'
import Separator from './ui/separator/Separator.vue';
const props = defineProps<{
modelValue?: string | null | undefined
placeholder?: string | null
}>()
const editor = ref<Editor>()
const menu = ref()
const getContent = (): string => editor.value?.getHTML() || ''
const isFocused = (): boolean => editor.value?.isFocused || false
@@ -34,10 +32,21 @@ onMounted(() => {
if (!editor.value) return
emit('update:modelValue', editor.value.getHTML())
},
onFocus: () => {
// menu.value = h(TextEditorMenu, {
// editor: editor.value
// });
// render(menu.value, document.body)
},
onBlur: () => {
if (!editor.value) return
emit('change:modelValue', editor.value.getHTML())
}
// if (menu.value) {
// render(null, document.body, menu.value.el)
// menu.value = null
// }
},
})
})
@@ -49,92 +58,15 @@ onBeforeUnmount(() => {
<template>
<!-- Editor -->
<div>
<!-- Menu -->
<ButtonGroup class="editor-menu shadow border rounded-md overflow-clip z-1 bg-background">
<Button @click="editor?.chain().focus().undo().run()" :disabled="!editor?.can().undo()" size="sm"
variant="ghost">
<Undo2 />
</Button>
<Button @click="editor?.chain().focus().redo().run()" :disabled="!editor?.can().redo()" size="sm"
variant="ghost">
<redo2 />
</Button>
<ButtonGroupSeparator/>
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button size="sm" variant="ghost">
<heading />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="editor?.chain().focus().clearNodes().run()" size="sm" variant="ghost">
<pilcrow /> Absatz
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem @click="editor?.chain().focus().toggleHeading({ level: 1 }).run()">
<heading1 /> Überschrift 1
</DropdownMenuItem>
<DropdownMenuItem @click="editor?.chain().focus().toggleHeading({ level: 2 }).run()">
<heading2 /> Überschrift 2
</DropdownMenuItem>
<DropdownMenuItem @click="editor?.chain().focus().toggleHeading({ level: 3 }).run()">
<heading3 /> Überschrift 3
</DropdownMenuItem>
<DropdownMenuItem @click="editor?.chain().focus().toggleHeading({ level: 4 }).run()">
<heading4 /> Überschrift 4
</DropdownMenuItem>
<DropdownMenuItem @click="editor?.chain().focus().toggleHeading({ level: 5 }).run()">
<heading5 /> Überschrift 5
</DropdownMenuItem>
<DropdownMenuItem @click="editor?.chain().focus().toggleHeading({ level: 6 }).run()">
<heading6 /> Überschrift 6
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<dropdown-menu>
<dropdown-menu-trigger as-child>
<Button size="sm" variant="ghost">
<list />
</Button>
</dropdown-menu-trigger>
<dropdown-menu-content>
<dropdown-menu-item @click="editor?.chain().focus().toggleBulletList().run()">
<list /> Ungeordnete Liste
</dropdown-menu-item>
<dropdown-menu-item @click="editor?.chain().focus().toggleOrderedList().run()"><list-ordered />
Geordnete Liste</dropdown-menu-item>
</dropdown-menu-content>
</dropdown-menu>
<ButtonGroupSeparator/>
<Button @click="editor?.chain().focus().toggleBold().run()" :class="{ 'is-active': editor?.isActive('bold') }"
size="sm" variant="ghost">
<Bold />
</Button>
<Button @click="editor?.chain().focus().toggleItalic().run()" size="sm" variant="ghost">
<Italic />
</Button>
<Button @click="editor?.chain().focus().toggleStrike().run()" size="sm" variant="ghost">
<strikethrough />
</Button>
<Button @click="editor?.chain().focus().toggleCode().run()" size="sm" variant="ghost">
<code2 />
</Button>
</ButtonGroup>
<EditorContent :editor="editor" class="editor mb-8" />
<div class="absolute top-0.75 py-2 px-0.75 italic text-muted-foreground pointer-events-none"
:class="{ 'hidden': !editor?.isEmpty }">Beschreibung</div>
<div v-bind:spellcheck="editor?.isFocused">
<TextEditorMenu :editor="editor" />
<div class="relative">
<EditorContent :editor="editor" class="editor mb-8 content" />
<!-- Placeholder -->
<div class="absolute top-0 italic text-muted-foreground pointer-events-none"
:class="{ 'hidden': !editor?.isEmpty }">{{ props.placeholder || 'Beschreibung' }}</div>
</div>
</div>
</template>
<style></style>
+104
View File
@@ -0,0 +1,104 @@
<script setup lang="ts">
import { Editor, } from '@tiptap/vue-3'
import { computed, onMounted, ref } from 'vue';
import { ButtonGroup, ButtonGroupSeparator } from './ui/button-group';
import { Button } from './ui/crm-button';
import { Bold, Code2, Heading, Heading1, Heading2, Heading3, Heading4, Heading5, Heading6, Italic, List, ListOrdered, Pilcrow, Redo2, Strikethrough, Undo2 } from 'lucide-vue-next';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator } from '@/components/ui/dropdown-menu'
const props = defineProps<{
editor: Editor | undefined
}>()
const position = ref({ top: 0, left: 0 })
onMounted(() => {
position.value.left = props.editor?.options.element.getBoundingClientRect().x
position.value.top = props.editor?.options.element.getBoundingClientRect().y
})
const positionStyle = computed(() => {
return 'top: calc(' + position.value.top + 'px - var(--spacing) * 9); ' +
'left: ' + position.value.left + 'px;'
})
</script>
<template>
<ButtonGroup
class="editor-menu z-50 border shadow rounded-md overflow-clip bg-background pointer-events-auto"
:style="positionStyle">
<Button @click="editor?.chain().focus().undo().run()" :disabled="!editor?.can().undo()" size="sm"
variant="ghost">
<Undo2 />
</Button>
<Button @click="editor?.chain().focus().redo().run()" :disabled="!editor?.can().redo()" size="sm"
variant="ghost">
<redo2 />
</Button>
<ButtonGroupSeparator />
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button size="sm" variant="ghost">
<heading />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem @click="editor?.chain().focus().clearNodes().run()" size="sm">
<pilcrow /> Absatz
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem @click="editor?.chain().focus().toggleHeading({ level: 1 }).run()">
<heading1 /> Überschrift 1
</DropdownMenuItem>
<DropdownMenuItem @click="editor?.chain().focus().toggleHeading({ level: 2 }).run()">
<heading2 /> Überschrift 2
</DropdownMenuItem>
<DropdownMenuItem @click="editor?.chain().focus().toggleHeading({ level: 3 }).run()">
<heading3 /> Überschrift 3
</DropdownMenuItem>
<DropdownMenuItem @click="editor?.chain().focus().toggleHeading({ level: 4 }).run()">
<heading4 /> Überschrift 4
</DropdownMenuItem>
<DropdownMenuItem @click="editor?.chain().focus().toggleHeading({ level: 5 }).run()">
<heading5 /> Überschrift 5
</DropdownMenuItem>
<DropdownMenuItem @click="editor?.chain().focus().toggleHeading({ level: 6 }).run()">
<heading6 /> Überschrift 6
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<dropdown-menu>
<dropdown-menu-trigger as-child>
<Button size="sm" variant="ghost">
<list />
</Button>
</dropdown-menu-trigger>
<dropdown-menu-content>
<dropdown-menu-item @click="editor?.chain().focus().toggleBulletList().run()">
<list /> Ungeordnete Liste
</dropdown-menu-item>
<dropdown-menu-item @click="editor?.chain().focus().toggleOrderedList().run()"><list-ordered />
Geordnete Liste</dropdown-menu-item>
</dropdown-menu-content>
</dropdown-menu>
<ButtonGroupSeparator />
<Button @click="editor?.chain().focus().toggleBold().run()"
:class="{ 'is-active': editor?.isActive('bold') }" size="sm" variant="ghost">
<Bold />
</Button>
<Button @click="editor?.chain().focus().toggleItalic().run()" size="sm" variant="ghost">
<Italic />
</Button>
<Button @click="editor?.chain().focus().toggleStrike().run()" size="sm" variant="ghost">
<strikethrough />
</Button>
<Button @click="editor?.chain().focus().toggleCode().run()" size="sm" variant="ghost">
<code2 />
</Button>
</ButtonGroup>
</template>
+20 -9
View File
@@ -1,13 +1,14 @@
<script setup lang="ts">
import { ref, computed, watch } from "vue"
import { ref, computed, watch, onMounted } from "vue"
import { Todo } from "@/types";
import { toLocalDate, toDuration, isToday, daysFromNow } from "@/lib/utils";
import { Mail, PhoneCall, ClipboardCheck } from "lucide-vue-next"
import { Mail, PhoneCall, ClipboardCheck, Repeat } from "lucide-vue-next"
import { Badge } from '@/components/ui/crm-badge'
const props = defineProps<{
modelValue?: Todo[] | null
showCompleted?: boolean
showCompleted?: boolean | false
showTodoable?: boolean | false
}>()
const todos = ref<Todo[]>([])
const emit = defineEmits(['update:modelValue'])
@@ -17,6 +18,12 @@ watch(() => props.modelValue, value => {
todos.value = value as Todo[];
})
onMounted(() => {
if (props.modelValue) {
todos.value = props.modelValue as Todo[];
}
})
const groupedTodos = computed(() => {
const groups: Record<string, Todo[]> = {};
@@ -83,8 +90,10 @@ const shouldDisplay = (todo: Todo) => {
<template>
<div v-if="todos" v-for="(todos, groupKey) in groupedTodos" :key="groupKey">
<!-- Group header -->
<h3 class="mt-4 mb-2 text-sm text-muted-foreground"
:class="{ 'text-destructive! font-bold': groupKey === 'overdue', 'text-foreground! font-bold': groupKey === 'today' }">
<h3 class="mt-4 mb-2 text-sm text-muted-foreground" :class="{
'text-destructive! font-bold': groupKey === 'overdue',
'text-warning! font-bold': groupKey === 'today'
}">
{{ groupKey === 'today' ? 'Heute' : groupKey === 'overdue' ? 'Verspätet' : groupKey }}
</h3>
<hr>
@@ -118,13 +127,15 @@ const shouldDisplay = (todo: Todo) => {
<!-- Date -->
<div class="text-xs text-muted-foreground flex gap-3 items-center mt-1">
<Badge v-if="todoBadge(todo.title)" variant="outline">{{ todoBadge(todo.title)
<Badge v-if="props.showTodoable && todoBadge(todo.title)" variant="outline">{{ todoBadge(todo.title)
}}
</Badge>
<span v-if="todo.dueDate"
:class="{ 'text-destructive font-bold': todo.status?.toLowerCase() != 'completed' && daysFromNow(todo.dueDate) < 0 }">
<span v-if="todo.dueDate" :class="{
'text-destructive! font-bold': groupKey === 'overdue',
'text-warning! font-bold': groupKey === 'today'
}">
{{ toDuration(todo.dueDate) }}</span>
<!-- <Repeat v-if="todo.recurring" stroke-width="2" :size="14" /> -->
<Repeat v-if="todo.recurring" stroke-width="2" :size="14" />
</div>
</div>
@@ -105,7 +105,7 @@ const calcTaxes = (amount: number) => {
<template>
<Table class="relative document-table">
<TableHeader class="sticky top-0">
<TableHeader class="sticky -top-4 md:-top-6 lg:-top-8">
<TableRow>
<TableHead class="w-1/100 lg:w-1/100 hidden md:table-cell lg:pl-4 lg:pr-5">Nr.</TableHead>
<TableHead class="w-1/100 lg:w-1/20 text-center">Status</TableHead>
@@ -193,7 +193,7 @@ const calcTaxes = (amount: number) => {
</TableRow>
<TableRow v-if="totalNotIssued > 0">
<TableCell colspan="2" class="hidden lg:table-cell"></TableCell>
<TableCell class="hidden lg:table-cell"></TableCell>
<TableCell colspan="2" class="hidden md:table-cell"></TableCell>
<TableCell colspan="1"></TableCell>
<TableCell class="text-right tabular-nums w-1/100 font-bold">Nicht gestellt</TableCell>
@@ -30,6 +30,7 @@ import { Kbd, KbdGroup } from '@/components/ui/kbd'
import DialogClose from "../ui/dialog/DialogClose.vue"
import DialogCloseButton from "../DialogCloseButton/DialogCloseButton.vue"
import SendMailDialog from "../ui/send-mail-dialog/SendMailDialog.vue"
import TextEditor from "../TextEditor.vue"
const DEBUG = ref(false)
@@ -86,8 +87,8 @@ onMounted(async () => {
// Process each response
customers.value = responses[0].data
paymentTerms.value = responses[0].data
units.value = responses[0].data
paymentTerms.value = responses[1].data
units.value = responses[2].data
} catch (error) {
toast.error('Fehler beim Laden der Daten', error || String(error))
@@ -161,7 +162,7 @@ watch(invoice,
return;
}
// If no billing data is store in the invoice, generat ot from customer
// If no billing data is store in the invoice, generate ot from customer
if (!newValue.billingData) {
newValue.billingData = {
companyName: newValue.customer?.companyName || "",
@@ -176,7 +177,7 @@ watch(invoice,
contactSalutation: newValue.customer?.contacts && newValue.customer.contacts.length > 0 ? newValue.customer.contacts[0].salutation : "",
contactFirstName: newValue.customer?.contacts && newValue.customer.contacts.length > 0 ? newValue.customer.contacts[0].firstName : "",
contactLastName: newValue.customer?.contacts && newValue.customer.contacts.length > 0 ? newValue.customer.contacts[0].lastName : "",
paymentTerms: newValue.customer?.paymentTerms || paymentTermsData.value.length > 0 ? paymentTermsData.value[2] : null,
paymentTerms: newValue.customer?.paymentTerms || paymentTerms.value.length > 0 ? paymentTerms.value[2] : null,
}
}
@@ -395,6 +396,7 @@ const exportXml = function () {
const issueInvoice = function () {
if (!invoice.value) return;
invoice.value.paymentStatus = 'issued'
save()
}
const deleteInvoice = function () {
@@ -546,13 +548,12 @@ const handleFileUpload = async (event: Event) => {
<DialogHeader class="p-4 md:p-6 lg:p-12 pb-0 md:pb-2 lg:pb-8 flex flex-row items-start gap-12">
<div class="flex flex-col grow">
<DialogTitle class="text-primary-foreground font-bold text-left">
<DialogTitle class="text-primary font-bold text-left">
<h1>{{ title }}</h1>
</DialogTitle>
<DialogDescription>
<Input v-if="invoice" v-model="invoice.title" :id="'invoice-title'"
class="text-foreground md:text-base text-ellipsis px-0 bg-transparent dark:bg-transparent hover:bg-accent dark:hover:bg-accent/30 border-none shadow-none"
type="text" placeholder="Titel" />
<Input v-if="invoice" v-model="invoice.title" @update:model-value="isDirty = true"
:id="'invoice-title'" class="" type="text" placeholder="Titel" />
</DialogDescription>
</div>
@@ -899,7 +900,10 @@ const handleFileUpload = async (event: Event) => {
</div>
<div id="document-text" class="mt-6 md:mt-8 lg:mt-12">
<GrowingTextarea v-model="invoice.text" placeholder="Anschreiben"
<!-- <GrowingTextarea v-model="invoice.text" placeholder="Anschreiben"
class="font-light bg-transparent dark:bg-transparent hover:bg-accent dark:hover:bg-accent/30 border-none shadow-none" /> -->
<TextEditor v-model="invoice.text" placeholder="Anschreiben"
@change:model-value="isDirty = true"
class="font-light bg-transparent dark:bg-transparent hover:bg-accent dark:hover:bg-accent/30 border-none shadow-none" />
</div>
@@ -24,7 +24,7 @@ import { toast } from "vue-sonner";
import ButtonGroup from '../ui/button-group/ButtonGroup.vue';
const DEBUG = ref(true)
const DEBUG = ref(false)
const props = defineProps<{
isLoading?: boolean,
@@ -60,12 +60,12 @@ watch(() => props.lineItems, async (newLineItems) => {
updateFromParent.value = true
// Only update if the items actually changed
if (JSON.stringify(items.value) !== JSON.stringify(newLineItems)) {
items.value = (newLineItems ?? [])
} else {
console.log('already up to date, no change')
}
// items.value = (newLineItems ?? [])
// if (JSON.stringify(items.value) !== JSON.stringify(newLineItems)) {
// items.value = (newLineItems ?? [])
// } else {
// console.log('already up to date, no change')
// }
items.value = (newLineItems ?? [])
// Reset flag after next tick
await nextTick()
@@ -90,6 +90,7 @@ watch(items, (newItems) => {
// Don't emit changes in loading
if (props.isLoading || updateFromParent.value) return
recalculatePositions()
console.log('emit update:lineItems')
emit('update:lineItems', newItems)
}, { deep: true })
@@ -144,6 +145,7 @@ const deleteItem = (lineItem: LineItem) => {
}
const recalculatePositions = () => {
console.log('recalculatePositions')
for (let i = 0; i < items.value.length; i++) {
items.value[i].position = getNextPosition(i, items.value[i].isSection)
}
@@ -189,8 +191,7 @@ const recalculatePositions = () => {
<TableHead class="h-0 w-8"></TableHead>
</TableRow>
<draggable v-model="items" tag="tbody" item-key="position" handle=".handle" ghostClass="ghost"
@end="recalculatePositions">
<draggable v-model="items" tag="tbody" item-key="position" handle=".handle" ghostClass="ghost">
<template #item="{ element }">
<TableRow v-if="element.isSection">
@@ -205,8 +206,7 @@ const recalculatePositions = () => {
<GrowingTextarea v-model="element.description" placeholder="Text"
class="font-light bg-transparent dark:bg-transparent hover:bg-background/66 dark:hover:bg-background/66 py-0 px-1 m-0 border-none shadow-none" />
</TableCell>
<!-- Buttons -->
<TableCell class="w-8 text-right px-1">
<Button variant="ghost" size="sm" @click="deleteItem(element)"
@@ -13,7 +13,7 @@ export const badgeVariants = cva(
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-desctructive-foreground [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
"border-transparent bg-destructive text-destructive-foreground [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
warning:
"border-transparent bg-warning text-warning-foreground [a&]:hover:bg-warning/90 focus-visible:ring-warning/20 dark:focus-visible:ring-warning/40 dark:bg-warning/60",
outline: