Add title sections to invoices backend and frontend, #41

This commit is contained in:
2025-11-19 10:12:45 +01:00
parent 250c4538aa
commit d1656b56a0
7 changed files with 80 additions and 31 deletions
+4 -2
View File
@@ -405,7 +405,8 @@ public function store(Request $request)
'title' => 'nullable|string', 'title' => 'nullable|string',
'text' => 'nullable|string', 'text' => 'nullable|string',
'items' => 'nullable|array', 'items' => 'nullable|array',
'items.*.position' => 'required|integer', 'items.*.isSection' => 'nullable|boolean',
'items.*.position' => 'required|numeric',
'items.*.title' => 'nullable|string', 'items.*.title' => 'nullable|string',
'items.*.description' => 'nullable|string', 'items.*.description' => 'nullable|string',
'items.*.quantity' => 'nullable|numeric', 'items.*.quantity' => 'nullable|numeric',
@@ -491,7 +492,8 @@ public function update(Request $request, $id)
'text' => 'nullable|string', 'text' => 'nullable|string',
'items' => 'nullable|array', 'items' => 'nullable|array',
'items.*.id' => 'nullable|integer', 'items.*.id' => 'nullable|integer',
'items.*.position' => 'required|integer', 'items.*.isSection' => 'nullable|boolean',
'items.*.position' => 'required|numeric',
'items.*.title' => 'nullable|string', 'items.*.title' => 'nullable|string',
'items.*.description' => 'nullable|string', 'items.*.description' => 'nullable|string',
'items.*.quantity' => 'nullable|numeric', 'items.*.quantity' => 'nullable|numeric',
+1
View File
@@ -12,6 +12,7 @@ class LineItem extends Model
protected $fillable = [ protected $fillable = [
'invoice_id', 'invoice_id',
'position', 'position',
'is_section',
'title', 'title',
'description', 'description',
'quantity', 'quantity',
+5 -2
View File
@@ -8,8 +8,11 @@ class LineItemFactory extends Factory
{ {
public function definition(): array public function definition(): array
{ {
$isSection = rand(0, 10) > 7;
return [ return [
'position' => $this->faker->numberBetween(1, 10), 'position' => $this->faker->numberBetween(1, 10) + ($isSection ? 0.5 : 0),
'is_section' => $isSection,
'title' => $this->faker->words(3, true), 'title' => $this->faker->words(3, true),
'description' => $this->faker->sentence(), 'description' => $this->faker->sentence(),
'quantity' => $this->faker->numberBetween(1, 10), 'quantity' => $this->faker->numberBetween(1, 10),
@@ -17,4 +20,4 @@ public function definition(): array
'price' => $this->faker->randomFloat(2, 10, 500), 'price' => $this->faker->randomFloat(2, 10, 500),
]; ];
} }
} }
@@ -14,7 +14,8 @@ public function up(): void
Schema::create('line_items', function (Blueprint $table) { Schema::create('line_items', function (Blueprint $table) {
$table->id(); $table->id();
$table->foreignId('invoice_id')->constrained()->onDelete('cascade'); $table->foreignId('invoice_id')->constrained()->onDelete('cascade');
$table->integer('position')->default(0); $table->decimal('position', 10, 2)->default(0);
$table->boolean('is_section')->default(false);
$table->string('title')->nullable(); $table->string('title')->nullable();
$table->string('description')->nullable(); $table->string('description')->nullable();
$table->integer('quantity')->default(1); $table->integer('quantity')->default(1);
@@ -296,6 +296,7 @@ const save = async () => {
items: invoice.value.items.map(item => ({ items: invoice.value.items.map(item => ({
id: item.id, // Include ID for existing items id: item.id, // Include ID for existing items
position: item.position, position: item.position,
isSection: item.isSection,
type: item.type, type: item.type,
title: item.title, title: item.title,
description: item.description, description: item.description,
@@ -323,6 +324,7 @@ const save = async () => {
} finally { } finally {
// remove spinner from save button // remove spinner from save button
isSaving.value = false 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 }) items.value = (newLineItems ?? []).slice().sort(function (a, b) { return a.position - b.position })
}, { deep: true, once: true }) }, { deep: true, once: true })
const newItem = () => { const newItem = (isSection: boolean) => {
const position = items.value.length + 1 let item = newLineItem(isSection)
let item = newLineItem() item.position = getNextPosition(items.value.length, isSection)
item.position = position
items.value.push(item) 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 deleteItem = (lineItem: LineItem) => {
const index = items.value.indexOf(lineItem) const index = items.value.indexOf(lineItem)
if (index > -1) { if (index > -1) {
@@ -64,7 +74,7 @@ const deleteItem = (lineItem: LineItem) => {
const recalculatePositions = () => { const recalculatePositions = () => {
for (let i = 0; i < items.value.length; i++) { 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"> @end="recalculatePositions">
<template #item="{ element }"> <template #item="{ element }">
<TableRow> <TableRow v-if="element.isSection">
<TableCell class="px-0 handle px-1 cursor-move w-6"> <TableCell class="handle px-1 cursor-move w-6">
<GripVertical :size="18" :strokeWidth="1.5" class="text-muted-foreground" /> <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> </TableCell>
<!-- Pos. --> <!-- Pos. -->
@@ -159,14 +192,11 @@ const recalculatePositions = () => {
</TableCell> </TableCell>
<!-- Buttons --> <!-- Buttons -->
<TableCell class="w-8 text-right"> <TableCell class="w-8 text-right px-1">
<Button variant="ghost" size="sm" @click="deleteItem(element)" <Button variant="ghost" size="sm" @click="deleteItem(element)"
class="has-[>svg]:px-1 text-muted-foreground hover:text-destructive"> class="has-[>svg]:px-1 text-muted-foreground hover:text-destructive">
<Trash2 :size="18" /> <Trash2 :size="18" stroke-width="1.5"/>
</Button> </Button>
<!-- <Button :variant="'ghost'" :size="'sm'" @click="" class="has-[>svg]:px-1 text-muted-foreground">
<CirclePlus />
</Button> -->
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -175,12 +205,15 @@ const recalculatePositions = () => {
<TableFooter v-if="items.length >= 1" class="bg-transparent"> <TableFooter v-if="items.length >= 1" class="bg-transparent">
<TableRow class="hover:bg-transparent dark:hover:bg-transparent"> <TableRow class="hover:bg-transparent dark:hover:bg-transparent">
<TableCell colspan="8" class="text-center"> <TableCell colspan="8">
<div class="flex gap-2 justify-center">
<Button class="mt-4" variant="ghost" @click="newItem"> <Button class="mt-4" variant="ghost" @click="newItem(true)">
<Plus /> Neue Zeile <Plus /> Neuer Abschnitt
</Button> </Button>
<Button class="mt-4" variant="ghost" @click="newItem(false)">
<Plus /> Neue Zeile
</Button>
</div>
</TableCell> </TableCell>
</TableRow> </TableRow>
</TableFooter> </TableFooter>
@@ -197,9 +230,14 @@ const recalculatePositions = () => {
<EmptyTitle>Diese Rechnung ist leer</EmptyTitle> <EmptyTitle>Diese Rechnung ist leer</EmptyTitle>
<EmptyDescription>Erstelle hier deinen ersten Posten</EmptyDescription> <EmptyDescription>Erstelle hier deinen ersten Posten</EmptyDescription>
</EmptyHeader> </EmptyHeader>
<Button variant="action" @click="newItem"> <div class="flex gap-2">
<Plus /> Neue Zeile <Button variant="action" @click="newItem(true)">
</Button> <Plus stroke-width="1.5" /> Neuer Abschnitt
</Button>
<Button variant="action" @click="newItem(false)">
<Plus stroke-width="1.5" /> Neue Zeile
</Button>
</div>
</Empty> </Empty>
</div> </div>
+6 -4
View File
@@ -203,9 +203,10 @@ export function newBillingData() {
} }
export interface LineItem { export interface LineItem {
id: number; id: number,
invoiceId: number, invoiceId: number,
position: number; position: number,
isSection: boolean,
type: string, type: string,
title: string, title: string,
description: string, description: string,
@@ -216,15 +217,16 @@ export interface LineItem {
export type LineItemType = LineItem export type LineItemType = LineItem
export function newLineItem(): LineItem { export function newLineItem(isSection: boolean): LineItem {
return { return {
id: 0, id: 0,
invoiceId: 0, invoiceId: 0,
position: 0, position: 0,
isSection: isSection,
type: 'service', type: 'service',
title: '', title: '',
description: '', description: '',
quantity: 1, quantity: 0,
unit: 'Stunden', unit: 'Stunden',
price: 93.75, price: 93.75,
} }