Add title sections to invoices backend and frontend, #41

This commit is contained in:
2025-11-19 10:12:45 +01:00
parent 1b76c6c61b
commit ebbbe42cc3
7 changed files with 80 additions and 31 deletions
@@ -296,6 +296,7 @@ const save = async () => {
items: invoice.value.items.map(item => ({
id: item.id, // Include ID for existing items
position: item.position,
isSection: item.isSection,
type: item.type,
title: item.title,
description: item.description,
@@ -323,6 +324,7 @@ const save = async () => {
} finally {
// remove spinner from save button
isSaving.value = false
setTimeout(() => { isDirty.value = false }, 1000)
}
}
@@ -47,13 +47,23 @@ watch(() => props.lineItems, (newLineItems) => {
items.value = (newLineItems ?? []).slice().sort(function (a, b) { return a.position - b.position })
}, { deep: true, once: true })
const newItem = () => {
const position = items.value.length + 1
let item = newLineItem()
item.position = position
const newItem = (isSection: boolean) => {
let item = newLineItem(isSection)
item.position = getNextPosition(items.value.length, isSection)
items.value.push(item)
}
const getNextPosition = (index: number, isSection: boolean) => {
let lastPosition = (index == 0) ? 0 : items.value.at(index - 1)?.position ?? 0
let nextInteger = (lastPosition == Math.ceil(lastPosition)) ? lastPosition + 1 : Math.ceil(lastPosition)
// item: next integer
// sections: middle between last element and next integer
return isSection ?
lastPosition + (nextInteger - lastPosition) / 2
: nextInteger
}
const deleteItem = (lineItem: LineItem) => {
const index = items.value.indexOf(lineItem)
if (index > -1) {
@@ -64,7 +74,7 @@ const deleteItem = (lineItem: LineItem) => {
const recalculatePositions = () => {
for (let i = 0; i < items.value.length; i++) {
items.value[i].position = i + 1
items.value[i].position = getNextPosition(i, items.value[i].isSection)
}
}
@@ -99,9 +109,32 @@ const recalculatePositions = () => {
@end="recalculatePositions">
<template #item="{ element }">
<TableRow>
<TableCell class="px-0 handle px-1 cursor-move w-6">
<GripVertical :size="18" :strokeWidth="1.5" class="text-muted-foreground" />
<TableRow v-if="element.isSection">
<TableCell class="handle px-1 cursor-move w-6">
<GripVertical :size="18" stroke-width="1.5" class="text-muted-foreground" />
</TableCell>
<!-- Posten -->
<TableCell colspan="6" class="pt-6">
<Input v-model="element.title" placeholder="Titel"
class="font-bold text-base! p-1 h-7! m-0 -mb-1 bg-transparent dark:bg-transparent hover:bg-background/66 dark:hover:bg-background/66 border-none hover:border-1 dark:hover:border-1 placeholder:text-muted-foreground/30 shadow-none" />
<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)"
class="has-[>svg]:px-1 text-muted-foreground hover:text-destructive">
<Trash2 :size="18" stroke-width="1.5"/>
</Button>
</TableCell>
</TableRow>
<TableRow v-else>
<TableCell class="handle px-1 cursor-move w-6">
<GripVertical :size="18" stroke-width="1.5" class="text-muted-foreground" />
</TableCell>
<!-- Pos. -->
@@ -159,14 +192,11 @@ const recalculatePositions = () => {
</TableCell>
<!-- Buttons -->
<TableCell class="w-8 text-right">
<TableCell class="w-8 text-right px-1">
<Button variant="ghost" size="sm" @click="deleteItem(element)"
class="has-[>svg]:px-1 text-muted-foreground hover:text-destructive">
<Trash2 :size="18" />
<Trash2 :size="18" stroke-width="1.5"/>
</Button>
<!-- <Button :variant="'ghost'" :size="'sm'" @click="" class="has-[>svg]:px-1 text-muted-foreground">
<CirclePlus />
</Button> -->
</TableCell>
</TableRow>
@@ -175,12 +205,15 @@ const recalculatePositions = () => {
<TableFooter v-if="items.length >= 1" class="bg-transparent">
<TableRow class="hover:bg-transparent dark:hover:bg-transparent">
<TableCell colspan="8" class="text-center">
<Button class="mt-4" variant="ghost" @click="newItem">
<Plus /> Neue Zeile
</Button>
<TableCell colspan="8">
<div class="flex gap-2 justify-center">
<Button class="mt-4" variant="ghost" @click="newItem(true)">
<Plus /> Neuer Abschnitt
</Button>
<Button class="mt-4" variant="ghost" @click="newItem(false)">
<Plus /> Neue Zeile
</Button>
</div>
</TableCell>
</TableRow>
</TableFooter>
@@ -197,9 +230,14 @@ const recalculatePositions = () => {
<EmptyTitle>Diese Rechnung ist leer</EmptyTitle>
<EmptyDescription>Erstelle hier deinen ersten Posten</EmptyDescription>
</EmptyHeader>
<Button variant="action" @click="newItem">
<Plus /> Neue Zeile
</Button>
<div class="flex gap-2">
<Button variant="action" @click="newItem(true)">
<Plus stroke-width="1.5" /> Neuer Abschnitt
</Button>
<Button variant="action" @click="newItem(false)">
<Plus stroke-width="1.5" /> Neue Zeile
</Button>
</div>
</Empty>
</div>
+6 -4
View File
@@ -203,9 +203,10 @@ export function newBillingData() {
}
export interface LineItem {
id: number;
id: number,
invoiceId: number,
position: number;
position: number,
isSection: boolean,
type: string,
title: string,
description: string,
@@ -216,15 +217,16 @@ export interface LineItem {
export type LineItemType = LineItem
export function newLineItem(): LineItem {
export function newLineItem(isSection: boolean): LineItem {
return {
id: 0,
invoiceId: 0,
position: 0,
isSection: isSection,
type: 'service',
title: '',
description: '',
quantity: 1,
quantity: 0,
unit: 'Stunden',
price: 93.75,
}