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',
|
'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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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 /> Neuer Abschnitt
|
||||||
|
</Button>
|
||||||
|
<Button class="mt-4" variant="ghost" @click="newItem(false)">
|
||||||
<Plus /> Neue Zeile
|
<Plus /> Neue Zeile
|
||||||
</Button>
|
</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)">
|
||||||
|
<Plus stroke-width="1.5" /> Neuer Abschnitt
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button variant="action" @click="newItem(false)">
|
||||||
|
<Plus stroke-width="1.5" /> Neue Zeile
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</Empty>
|
</Empty>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Vendored
+6
-4
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user