Add title sections to invoices backend and frontend, #41
This commit is contained in:
@@ -405,7 +405,8 @@ public function store(Request $request)
|
||||
'title' => 'nullable|string',
|
||||
'text' => 'nullable|string',
|
||||
'items' => 'nullable|array',
|
||||
'items.*.position' => 'required|integer',
|
||||
'items.*.isSection' => 'nullable|boolean',
|
||||
'items.*.position' => 'required|numeric',
|
||||
'items.*.title' => 'nullable|string',
|
||||
'items.*.description' => 'nullable|string',
|
||||
'items.*.quantity' => 'nullable|numeric',
|
||||
@@ -491,7 +492,8 @@ public function update(Request $request, $id)
|
||||
'text' => 'nullable|string',
|
||||
'items' => 'nullable|array',
|
||||
'items.*.id' => 'nullable|integer',
|
||||
'items.*.position' => 'required|integer',
|
||||
'items.*.isSection' => 'nullable|boolean',
|
||||
'items.*.position' => 'required|numeric',
|
||||
'items.*.title' => 'nullable|string',
|
||||
'items.*.description' => 'nullable|string',
|
||||
'items.*.quantity' => 'nullable|numeric',
|
||||
|
||||
@@ -12,6 +12,7 @@ class LineItem extends Model
|
||||
protected $fillable = [
|
||||
'invoice_id',
|
||||
'position',
|
||||
'is_section',
|
||||
'title',
|
||||
'description',
|
||||
'quantity',
|
||||
|
||||
@@ -8,8 +8,11 @@ class LineItemFactory extends Factory
|
||||
{
|
||||
public function definition(): array
|
||||
{
|
||||
|
||||
$isSection = rand(0, 10) > 7;
|
||||
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),
|
||||
'description' => $this->faker->sentence(),
|
||||
'quantity' => $this->faker->numberBetween(1, 10),
|
||||
@@ -17,4 +20,4 @@ public function definition(): array
|
||||
'price' => $this->faker->randomFloat(2, 10, 500),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@ public function up(): void
|
||||
Schema::create('line_items', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$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('description')->nullable();
|
||||
$table->integer('quantity')->default(1);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Vendored
+6
-4
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user