Add LineItem CSV import and fix Unit API

This commit is contained in:
2025-12-08 13:20:52 +01:00
parent ee6525b549
commit 7ddf1337c1
12 changed files with 437 additions and 59 deletions
+19 -5
View File
@@ -1,10 +1,12 @@
<?php <?php
namespace App\Http\Controllers; namespace App\Http\Controllers;
use Inertia\Inertia; use Inertia\Inertia;
use App\Models\Invoice; use App\Models\Invoice;
use App\Models\LineItem; use App\Models\LineItem;
use App\Models\Setting; use App\Models\Setting;
use App\Models\Unit;
use App\Mail\Reminder; use App\Mail\Reminder;
use App\Support\ApiDataTransformer; use App\Support\ApiDataTransformer;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -107,6 +109,16 @@ public static function single($id)
])->findOrFail($id); ])->findOrFail($id);
$invoiceArray = $invoice->toArray(); $invoiceArray = $invoice->toArray();
$unitIds = array_unique(array_column($invoiceArray['items'], 'unit_id'));
$units = Unit::whereIn('id', $unitIds)->get()->keyBy('id');
$invoiceArray['items'] = array_map(function ($item) use ($units) {
if (isset($item['unit_id']) && $units->has($item['unit_id'])) {
$item['unit'] = $units->get($item['unit_id'])->toArray();
}
return $item;
}, $invoiceArray['items']);
$invoiceArray['customer']['payment_terms'] = $invoice->customer->paymentTerms->toArray(); $invoiceArray['customer']['payment_terms'] = $invoice->customer->paymentTerms->toArray();
unset($invoiceArray['customer']['payment_terms_id']); unset($invoiceArray['customer']['payment_terms_id']);
@@ -211,7 +223,9 @@ public function exportPdf($id)
'isPDF' => true, 'isPDF' => true,
'fontPath' => public_path('storage/fonts/'), 'fontPath' => public_path('storage/fonts/'),
'giroCode' => $giroCode, 'giroCode' => $giroCode,
'totalPages' => 1 'totalPages' => 1,
'companyName' => Setting::get('company.name'),
'companyAddress' => json_decode(Setting::get('company.address'), true)
]; ];
$options = [ $options = [
@@ -476,7 +490,7 @@ public function store(Request $request)
'items.*.title' => 'nullable|string', 'items.*.title' => 'nullable|string',
'items.*.description' => 'nullable|string', 'items.*.description' => 'nullable|string',
'items.*.quantity' => 'nullable|numeric', 'items.*.quantity' => 'nullable|numeric',
'items.*.unit' => 'nullable|string', 'items.*.unitId' => 'nullable|integer',
'items.*.price' => 'nullable|numeric', 'items.*.price' => 'nullable|numeric',
]); ]);
@@ -563,7 +577,7 @@ public function update(Request $request, $id)
'items.*.title' => 'nullable|string', 'items.*.title' => 'nullable|string',
'items.*.description' => 'nullable|string', 'items.*.description' => 'nullable|string',
'items.*.quantity' => 'nullable|numeric', 'items.*.quantity' => 'nullable|numeric',
'items.*.unit' => 'nullable|string', 'items.*.unitId' => 'nullable|integer',
'items.*.price' => 'nullable|numeric', 'items.*.price' => 'nullable|numeric',
]); ]);
@@ -594,8 +608,8 @@ public function update(Request $request, $id)
if (!empty($snakeCaseData['items'])) { if (!empty($snakeCaseData['items'])) {
foreach ($snakeCaseData['items'] as $item) { foreach ($snakeCaseData['items'] as $item) {
// Remove unit from item data if it's empty to let the database use the default value // Remove unit from item data if it's empty to let the database use the default value
if (empty($item['unit'])) { if (empty($item['unitId'])) {
unset($item['unit']); unset($item['unitId']);
} }
if (isset($item['id']) && $item['id'] > 0) { if (isset($item['id']) && $item['id'] > 0) {
// Update existing item // Update existing item
+175
View File
@@ -3,7 +3,9 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\LineItem; use App\Models\LineItem;
use App\Models\Unit;
use App\Support\ApiDataTransformer; use App\Support\ApiDataTransformer;
use Illuminate\Http\Request;
class LineItemController extends Controller class LineItemController extends Controller
{ {
@@ -26,4 +28,177 @@ public function index($invoiceId)
return ApiDataTransformer::snakeToCamel($itemArray); return ApiDataTransformer::snakeToCamel($itemArray);
}); });
} }
/**
* Imports LineItems from a CSV file for a specific invoice
*
* CSV Format Requirements:
* 1. The CSV file must be semicolon-separated (use ; as delimiter) to avoid conflicts with commas in numeric values
* 2. The CSV file must contain the following columns (in any order):
* - position (optional): The position of the item in the invoice (if not provided, items will be numbered sequentially starting from 1)
* - title (required): The title of the item
* - description (optional): A description of the item
* - quantity (optional): The quantity (default: 1). Can use comma as decimal separator and dot as thousands separator (e.g., 1.500,50 for 1500.50)
* - unit (required): The unit (must exist in the units table, either as name or symbol)
* - price (optional): The price per unit (default: 0). Can use comma as decimal separator and dot as thousands separator (e.g., 1.500,50 for 1500.50)
*
* 3. The first row can optionally contain a header row with column names
* 4. Each row must contain at least the 'title' and 'unit' columns
* 5. Empty rows or rows with incomplete data will be skipped
* 6. The CSV file must have the extension .csv or .txt
*
* Example CSV (with header):
* position;title;description;quantity;unit;price
* 1;"Webdesign";"Development of a new website";10;"Hours";93,75
* ;"Hosting";"Annual hosting";1.500,50;"lump sum";1.200,00
*
* Example CSV (without header):
* 1;"Webdesign";"Development of a new website";10;"Hours";93,75
* ;"Hosting";"Annual hosting";1.500,50;"lump sum";1.200,00
*
* @param Request $request The HTTP request with the CSV file
* @param int $invoiceId The ID of the invoice for which the items will be imported
* @return \Illuminate\Http\JsonResponse A JSON response with the imported items or an error message
*/
public function importFromCsv(Request $request, $invoiceId)
{
$request->validate([
'csv' => 'required|file|mimes:csv,txt'
]);
$file = $request->file('csv');
$csvData = array_map(function ($row) {
// Use semicolon as delimiter
return str_getcsv($row, ';');
}, file($file->getRealPath()));
// Skip header row if it exists
if (count($csvData) > 0 && count($csvData[0]) > 0) {
$headers = array_map('trim', $csvData[0]);
$data = array_slice($csvData, 1);
} else {
$headers = [];
$data = $csvData;
}
// Default headers if none provided
if (empty($headers)) {
$headers = ['position', 'title', 'description', 'quantity', 'unit', 'price'];
}
$lineItems = [];
$units = Unit::all();
$positionCounter = 1;
// Create a NumberFormatter for German locale
$formatter = new \NumberFormatter('de_DE', \NumberFormatter::DECIMAL);
foreach ($data as $row) {
if (count($row) < count($headers)) {
continue; // Skip incomplete rows
}
$itemData = array_combine($headers, $row);
$itemData = array_map('trim', $itemData);
// Validate required fields
if (empty($itemData['title']) || empty($itemData['unit'])) {
continue;
}
// Find unit by name or symbol
$unit = $units->first(function ($u) use ($itemData) {
return strtolower($u->name) === strtolower($itemData['unit']) ||
strtolower($u->symbol) === strtolower($itemData['unit']);
});
if (!$unit) {
return response()->json([
'message' => "Unit '{$itemData['unit']}' not found in database. Please add it first."
], 400);
}
// Determine position
$position = !empty($itemData['position']) ? (float)$itemData['position'] : $positionCounter++;
// Parse quantity using NumberFormatter
$quantity = $this->parseNumber($formatter, $itemData['quantity'] ?? 1);
// Parse price using NumberFormatter
$price = $this->parseNumber($formatter, $itemData['price'] ?? 0);
// Validate numeric values
if (!is_numeric($quantity) || !is_numeric($price)) {
continue; // Skip rows with invalid numeric values
}
$lineItems[] = [
'invoice_id' => $invoiceId,
'position' => $position,
'is_section' => false,
'title' => $itemData['title'],
'description' => $itemData['description'] ?? '',
'quantity' => (float)$quantity,
'unit_id' => $unit->id,
'price' => (float)$price,
'created_at' => now(),
'updated_at' => now()
];
}
if (empty($lineItems)) {
return response()->json(['message' => 'No valid items found in the CSV file'], 400);
}
// Delete existing items for this invoice
LineItem::where('invoice_id', $invoiceId)->delete();
// Insert new items
LineItem::insert($lineItems);
// Return the newly created items
$items = LineItem::with('unit')
->where('invoice_id', $invoiceId)
->orderBy('position', 'asc')
->get();
return $items->map(function ($item) {
$itemArray = $item->toArray();
if ($item->unit) {
$itemArray['unit_name'] = $item->unit->name;
$itemArray['unit_symbol'] = $item->unit->symbol;
}
return ApiDataTransformer::snakeToCamel($itemArray);
});
}
/**
* Parses a number using NumberFormatter for the specified locale
*
* @param \NumberFormatter $formatter The NumberFormatter instance
* @param string|float $value The value to parse
* @return float The parsed number
*/
protected function parseNumber(\NumberFormatter $formatter, $value)
{
if (is_numeric($value)) {
return (float)$value;
}
if (!is_string($value)) {
return 0.0;
}
// Parse the number using NumberFormatter
$parsed = $formatter->parse($value);
// Check if parsing was successful
if ($parsed === false) {
return 0.0;
}
return (float)$parsed;
}
} }
+101
View File
@@ -0,0 +1,101 @@
<?php
namespace App\Http\Controllers;
use App\Models\Unit;
use Illuminate\Http\Request;
use App\Support\ApiDataTransformer;
use Illuminate\Support\Facades\Validator;
class UnitController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index()
{
$units = Unit::all()->toArray();
return ApiDataTransformer::snakeToCamel($units);
}
/**
* Store a newly created resource in storage.
*/
public function store(Request $request)
{
$validator = Validator::make($request->all(), [
'name' => 'required|string|max:50|unique:units',
'symbol' => 'nullable|string|max:10',
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed',
'errors' => $validator->errors()
], 422);
}
// Wandeln Sie die Daten von camelCase in snake_case um
$data = ApiDataTransformer::camelToSnake($request->all());
$unit = Unit::create($data);
return response()->json([
'message' => 'Unit created successfully',
'data' => ApiDataTransformer::snakeToCamel($unit->toArray())
], 201);
}
/**
* Display the specified resource.
*/
public function show(Unit $unit)
{
return ApiDataTransformer::snakeToCamel($unit->toArray());
}
/**
* Update the specified resource in storage.
*/
public function update(Request $request, Unit $unit)
{
$validator = Validator::make($request->all(), [
'name' => 'sometimes|required|string|max:50|unique:units,name,' . $unit->id,
'symbol' => 'nullable|string|max:10',
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed',
'errors' => $validator->errors()
], 422);
}
// Wandeln Sie die Daten von camelCase in snake_case um
$data = ApiDataTransformer::camelToSnake($request->all());
$unit->update($data);
return response()->json([
'message' => 'Unit updated successfully',
'data' => ApiDataTransformer::snakeToCamel($unit->toArray())
]);
}
/**
* Remove the specified resource from storage.
*/
public function destroy(Unit $unit)
{
// Check if the unit is used by any products or line items
if ($unit->products()->exists() || $unit->lineItems()->exists()) {
return response()->json([
'message' => 'Cannot delete unit as it is used by products or line items'
], 409);
}
$unit->delete();
return response()->json([
'message' => 'Unit deleted successfully'
]);
}
}
+20 -15
View File
@@ -1,4 +1,5 @@
<?php <?php
namespace App\Support; namespace App\Support;
class ApiDataTransformer class ApiDataTransformer
@@ -11,7 +12,7 @@ public static function snakeToCamel(array $data): array
$result = []; $result = [];
foreach ($data as $key => $value) { foreach ($data as $key => $value) {
$camelKey = self::convertSnakeToCamel($key); $camelKey = lcfirst(str_replace(' ', '', ucwords(str_replace('_', ' ', $key))));
if (is_array($value)) { if (is_array($value)) {
$result[$camelKey] = self::snakeToCamel($value); $result[$camelKey] = self::snakeToCamel($value);
@@ -33,7 +34,7 @@ public static function camelToSnake(array $data): array
$result = []; $result = [];
foreach ($data as $key => $value) { foreach ($data as $key => $value) {
$snakeKey = self::convertCamelToSnake($key); $snakeKey = strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $key));
if (is_array($value)) { if (is_array($value)) {
$result[$snakeKey] = self::camelToSnake($value); $result[$snakeKey] = self::camelToSnake($value);
@@ -47,19 +48,23 @@ public static function camelToSnake(array $data): array
return $result; return $result;
} }
/**
* Convert a single string from snake_case to camelCase
*/
protected static function convertSnakeToCamel(string $string): string
{
return lcfirst(str_replace(' ', '', ucwords(str_replace('_', ' ', $string))));
}
/** protected function parseGermanNumber($value)
* Convert a single string from camelCase to snake_case
*/
protected static function convertCamelToSnake(string $string): string
{ {
return strtolower(preg_replace('/(?<!^)[A-Z]/', '_$0', $string)); if (is_numeric($value)) {
return (float)$value;
}
if (!is_string($value)) {
return 0.0;
}
// Remove all dots (thousands separators)
$value = str_replace('.', '', $value);
// Replace comma with dot for decimal separator
$value = str_replace(',', '.', $value);
return (float)$value;
} }
} }
@@ -18,10 +18,13 @@ public function up(): void
$table->boolean('is_section')->default(false); $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->decimal('quantity')->default(1);
$table->foreignId('unit_id')->nullable()->constrained()->nullOnDelete(); $table->foreignId('unit_id')->nullable()->constrained()->nullOnDelete();
$table->decimal('price', 10, 2)->nullable(); $table->decimal('price', 10, 2)->nullable();
$table->timestamps(); $table->timestamps();
$table->index('position');
$table->index('invoice_id');
}); });
} }
+1 -1
View File
@@ -9,7 +9,7 @@ class UnitSeeder extends Seeder
{ {
public function run(): void public function run(): void
{ {
Unit::create(['name' => 'Stück', 'symbol' => 'Stk']); Unit::create(['name' => 'Stück', 'symbol' => 'Stk.']);
Unit::create(['name' => 'Stunden', 'symbol' => 'h']); Unit::create(['name' => 'Stunden', 'symbol' => 'h']);
Unit::create(['name' => 'Tage', 'symbol' => 'd']); Unit::create(['name' => 'Tage', 'symbol' => 'd']);
Unit::create(['name' => 'pauschal', 'symbol' => 'p']); Unit::create(['name' => 'pauschal', 'symbol' => 'p']);
@@ -1,10 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { type Invoice } from '@/types' import { Invoice } from '@/types'
import { toLocalDate, toCurrency, toFixedRounded } from '@/lib/utils' import { toLocalDate, toCurrency, toFixedRounded } from '@/lib/utils'
import { StatusBadge, statusBadgeLabels, statusTextStyle, castToStatusVariant } from '@/components/ui/status-badge' import { StatusBadge, statusBadgeLabels, statusTextStyle, castToStatusVariant } from '@/components/ui/status-badge'
import { Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow } from '@/components/ui/table' import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from '@/components/ui/table'
const props = defineProps<{ const props = defineProps<{
@@ -176,8 +176,9 @@ const calcTaxes = (amount: number) => {
<TableCell class="py-4 text-right tabular-nums">{{ toCurrency(totalPaid) }}</TableCell> <TableCell class="py-4 text-right tabular-nums">{{ toCurrency(totalPaid) }}</TableCell>
<TableCell class=" text-right tabular-nums hidden lg:table-cell">{{ toCurrency(totalTaxPaid) }} <TableCell class=" text-right tabular-nums hidden lg:table-cell">{{ toCurrency(totalTaxPaid) }}
</TableCell> </TableCell>
<TableCell class="lg:pr-4 text-right tabular-nums hidden lg:table-cell font-bold">{{ toCurrency(totalGrossPaid) <TableCell class="lg:pr-4 text-right tabular-nums hidden lg:table-cell font-bold">{{
}}</TableCell> toCurrency(totalGrossPaid)
}}</TableCell>
</TableRow> </TableRow>
<TableRow v-if="totalDue > 0" class="border-none text-destructive hover:bg-transparent"> <TableRow v-if="totalDue > 0" class="border-none text-destructive hover:bg-transparent">
@@ -188,7 +189,8 @@ const calcTaxes = (amount: number) => {
<TableCell class="py-4 text-right tabular-nums">{{ toCurrency(totalDue) }}</TableCell> <TableCell class="py-4 text-right tabular-nums">{{ toCurrency(totalDue) }}</TableCell>
<TableCell class="text-right tabular-nums hidden lg:table-cell">{{ toCurrency(totalTaxDue) }} <TableCell class="text-right tabular-nums hidden lg:table-cell">{{ toCurrency(totalTaxDue) }}
</TableCell> </TableCell>
<TableCell class="lg:pr-4 text-right tabular-nums hidden lg:table-cell font-bold">{{ toCurrency(totalGrossDue) <TableCell class="lg:pr-4 text-right tabular-nums hidden lg:table-cell font-bold">{{
toCurrency(totalGrossDue)
}}</TableCell> }}</TableCell>
</TableRow> </TableRow>
@@ -213,9 +215,8 @@ const calcTaxes = (amount: number) => {
font-size: 0.833rem; font-size: 0.833rem;
} }
.document-table th { .document-table th {}
}
.document-table td { .document-table td {
padding-top: 1.125em !important; padding-top: 1.125em !important;
padding-bottom: 1.125em !important; padding-bottom: 1.125em !important;
@@ -10,7 +10,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, onMounted, onUpdated, toRaw } from "vue" import { ref, computed, watch, onMounted, onUpdated, toRaw } from "vue"
import { Customer, Invoice, Contact, PaymentTerms, Address, LineItem, PaymentStatus } from "@/types" import { Customer, Invoice, Contact, PaymentTerms, Address, LineItem, PaymentStatus, Unit } from "@/types"
import { newCustomer, newContact, newBillingData } from '@/types/index.d' import { newCustomer, newContact, newBillingData } from '@/types/index.d'
import { toCurrency, toLocalDate, toShortISOString, calcDueDate, toFixedRounded } from '@/lib/utils' import { toCurrency, toLocalDate, toShortISOString, calcDueDate, toFixedRounded } from '@/lib/utils'
import axios from 'axios' import axios from 'axios'
@@ -24,7 +24,7 @@ import { Input } from '@/components/ui/crm-input';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'
import { StatusBadge, statusBadgeLabels } from '@/components/ui/status-badge' import { StatusBadge, statusBadgeLabels } from '@/components/ui/status-badge'
import LineItemTable from '@/components/documents/LineItemTable.vue' import LineItemTable from '@/components/documents/LineItemTable.vue'
import { Eye, FileText, Trash2, BookUser, User, CodeXml, MessageCircleQuestion, Loader2, Ellipsis, Check, FileCheck, Ban, Logs } from "lucide-vue-next" import { Eye, FileText, Trash2, BookUser, User, CodeXml, MessageCircleQuestion, Loader2, Ellipsis, Check, FileCheck, Ban, Logs, Import } from "lucide-vue-next"
import { alertStore } from "@/stores/alertStore" import { alertStore } from "@/stores/alertStore"
import { GrowingTextarea } from '../ui/growing-textarea' import { GrowingTextarea } from '../ui/growing-textarea'
import { toast } from "vue-sonner" import { toast } from "vue-sonner"
@@ -38,7 +38,9 @@ const props = defineProps<{
modelValue: boolean modelValue: boolean
}>() }>()
const invoice = ref<Invoice>() const invoice = ref<Invoice>()
const units = ref([] as Unit[])
const customers = ref([] as Customer[]) const customers = ref([] as Customer[])
const paymentTermsData = ref([] as PaymentTerms[]) const paymentTermsData = ref([] as PaymentTerms[])
const isDirty = ref(false) const isDirty = ref(false)
@@ -51,6 +53,8 @@ const alert = alertStore()
const reminderDialogOpen = ref(false) const reminderDialogOpen = ref(false)
const reminderLoading = ref(false) const reminderLoading = ref(false)
const value = ref<DateValue>() // TODO: name properly const value = ref<DateValue>() // TODO: name properly
const fileInput = ref<HTMLInputElement | null>(null);
const emit = defineEmits(['update:modelValue', 'save', 'cancel', 'delete']) const emit = defineEmits(['update:modelValue', 'save', 'cancel', 'delete'])
const isOpen = computed({ const isOpen = computed({
@@ -74,10 +78,12 @@ onMounted(async () => {
const promises = []; const promises = [];
promises.push(axios.get('/api/customers')) promises.push(axios.get('/api/customers'))
promises.push(axios.get('/api/paymentterms')) promises.push(axios.get('/api/paymentterms'))
promises.push(axios.get('/api/units'))
const responses = await Promise.all(promises) const responses = await Promise.all(promises)
let responseIndex = 0 let responseIndex = 0
customers.value = responses[responseIndex].data as Customer[] customers.value = responses[responseIndex].data as Customer[]
paymentTermsData.value = responses[responseIndex + 1].data as PaymentTerms[] paymentTermsData.value = responses[responseIndex + 1].data as PaymentTerms[]
units.value = responses[responseIndex + 2].data as Unit[]
} catch (error) { } catch (error) {
toast.error('Fehler beim Laden der Daten', error || String(error)) toast.error('Fehler beim Laden der Daten', error || String(error))
} }
@@ -211,14 +217,14 @@ watch(importCustomer,
invoice.value.billingData.companyName = newValue.companyName invoice.value.billingData.companyName = newValue.companyName
invoice.value.billingData.vatId = newValue.vatId invoice.value.billingData.vatId = newValue.vatId
if (!invoice.value.billingData.billingAddress) // if (!invoice.value.billingData.billingAddress)
invoice.value.billingData.billingAddress = newValue.billingAddress as Address invoice.value.billingData.billingAddress = newValue.billingAddress as Address
if (!invoice.value.billingData.contactFirstName) // if (!invoice.value.billingData.contactFirstName)
invoice.value.billingData.contactFirstName = newValue.contacts && newValue.contacts.length > 0 ? newValue.contacts[0].firstName : '' invoice.value.billingData.contactFirstName = newValue.contacts && newValue.contacts.length > 0 ? newValue.contacts[0].firstName : ''
if (!invoice.value.billingData.contactLastName) // if (!invoice.value.billingData.contactLastName)
invoice.value.billingData.contactLastName = newValue.contacts && newValue.contacts.length > 0 ? newValue.contacts[0].lastName : '' invoice.value.billingData.contactLastName = newValue.contacts && newValue.contacts.length > 0 ? newValue.contacts[0].lastName : ''
if (!invoice.value.billingData.paymentTerms) // if (!invoice.value.billingData.paymentTerms)
invoice.value.billingData.paymentTerms = newValue.paymentTerms as PaymentTerms invoice.value.billingData.paymentTerms = newValue.paymentTerms as PaymentTerms
// console.warn('trigger invoice watcher') // console.warn('trigger invoice watcher')
invoice.value.customer = newValue invoice.value.customer = newValue
@@ -305,7 +311,7 @@ const save = async () => {
title: item.title, title: item.title,
description: item.description, description: item.description,
quantity: item.quantity, quantity: item.quantity,
unit: item.unit, unitId: item.unitId,
price: item.price price: item.price
})) }))
} }
@@ -470,6 +476,54 @@ const updateLineItems = (newItems: LineItem[]) => {
// console.groupEnd(); // console.groupEnd();
} }
const importLineItems = () => {
if (!fileInput.value) {
fileInput.value = document.createElement('input');
fileInput.value.type = 'file';
fileInput.value.accept = '.csv';
fileInput.value.addEventListener('change', handleFileUpload);
}
fileInput.value.click();
}
const handleFileUpload = async (event: Event) => {
const input = event.target as HTMLInputElement;
if (!input.files || !input.files[0]) return;
const file = input.files[0];
const formData = new FormData();
formData.append('csv', file);
try {
if (!invoice.value || !invoice.value.id) {
throw new Error('Rechnung muss gespeichert werden, bevor Positionen importiert werden können');
}
const response = await axios.post(`/api/lineitems/import/${invoice.value.id}`, formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
if (invoice.value) {
invoice.value.items = response.data;
isDirty.value = true;
}
toast.success('Positionen erfolgreich importiert');
} catch (error) {
console.error('Fehler beim Importieren der Positionen:', error);
toast.error('Fehler beim Importieren der Positionen', {
description: error instanceof Error ? error.message : String(error)
});
} finally {
// Reset file input
if (fileInput.value) {
fileInput.value.value = '';
}
}
}
</script> </script>
<template> <template>
@@ -553,11 +607,31 @@ const updateLineItems = (newItems: LineItem[]) => {
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<!-- Import Items -->
<DropdownMenuItem class="flex items-center justify-between" @click="importLineItems">
<div class="flex items-center gap-3">
<Import :strokeWidth="1.5" class="text-muted-foreground" />
<div class="mr-4 flex flex-col">
<span>Posten importieren</span>
<span class="text-xs text-muted-foreground">(CSV)</span>
</div>
</div>
<KbdGroup>
<Kbd class="visible-mac"></Kbd>
<Kbd class="visible-pc">Ctrl</Kbd>
<Kbd>I</Kbd>
</KbdGroup>
</DropdownMenuItem>
<DropdownMenuSeparator />
<!-- Preview --> <!-- Preview -->
<DropdownMenuItem v-if="invoice?.paymentStatus == 'draft'" <DropdownMenuItem v-if="invoice?.paymentStatus == 'draft'"
class="flex items-center justify-between" @click="preview"> class="flex items-center justify-between" @click="preview">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<Eye :strokeWidth="1.5" class="text-current" /> <Eye :strokeWidth="1.5" class="text-muted-foreground" />
<span class="mr-4">Vorschau</span> <span class="mr-4">Vorschau</span>
</div> </div>
<KbdGroup> <KbdGroup>
@@ -595,8 +669,8 @@ const updateLineItems = (newItems: LineItem[]) => {
</div> </div>
</div> </div>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator v-if="invoice && invoice.paymentStatus != 'draft'" />
<!-- Audit --> <!-- Audit -->
<DropdownMenuItem v-if="invoice && invoice.paymentStatus != 'draft'" <DropdownMenuItem v-if="invoice && invoice.paymentStatus != 'draft'"
@@ -821,8 +895,8 @@ const updateLineItems = (newItems: LineItem[]) => {
</div> </div>
<LineItemTable :lineItems="invoice.items" @update:lineItems="updateLineItems" sticky-top="7" <LineItemTable :lineItems="invoice.items" :units="units" @update:lineItems="updateLineItems"
:isLoading="itemsLoading" class="mt-4" /> sticky-top="7" :isLoading="itemsLoading" class="mt-4" />
</div> </div>
@@ -5,7 +5,7 @@
import { ref, watch, HTMLAttributes, onUpdated } from 'vue' import { ref, watch, HTMLAttributes, onUpdated } from 'vue'
import draggable from 'vuedraggable'; import draggable from 'vuedraggable';
import { cn, toCurrency } from '@/lib/utils'; import { cn, toCurrency } from '@/lib/utils';
import { LineItem } from '@/types'; import { LineItem, Unit } from '@/types';
import { newLineItem } from '@/types/index.d' import { newLineItem } from '@/types/index.d'
import { Table, TableCell, TableFooter, TableHead, TableHeader, TableRow, } from '@/components/ui/table'; import { Table, TableCell, TableFooter, TableHead, TableHeader, TableRow, } from '@/components/ui/table';
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select" import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"
@@ -20,6 +20,7 @@ import { GrowingTextarea } from '@/components/ui/growing-textarea';
const props = defineProps<{ const props = defineProps<{
isLoading?: boolean, isLoading?: boolean,
lineItems: LineItem[] | undefined, lineItems: LineItem[] | undefined,
units: Unit[],
stickyTop: number | string, stickyTop: number | string,
class?: HTMLAttributes['class'] class?: HTMLAttributes['class']
}>() }>()
@@ -29,7 +30,6 @@ const emit = defineEmits<{
}>() }>()
const isLoading = ref(props.isLoading || false) const isLoading = ref(props.isLoading || false)
const units = ref(['Stück', 'Stunden', 'Tage', 'pauschal'])
const items = ref((props.lineItems ?? []).slice().sort(function (a, b) { return a.position - b.position })) // items only uses props.lineItems as the initial value; const items = ref((props.lineItems ?? []).slice().sort(function (a, b) { return a.position - b.position })) // items only uses props.lineItems as the initial value;
onUpdated(() => { onUpdated(() => {
@@ -165,15 +165,15 @@ const recalculatePositions = () => {
<!-- Einh. --> <!-- Einh. -->
<TableCell class="w-1/8 text-center"> <TableCell class="w-1/8 text-center">
<Select v-model="element.unit"> <Select v-model="element.unitId">
<SelectTrigger <SelectTrigger
class="shadow-none bg-transparent p-1 h-7! dark:bg-transparent hover:bg-background/66 dark:hover:bg-background/66 border-none w-full"> class="shadow-none bg-transparent p-1 h-7! dark:bg-transparent hover:bg-background/66 dark:hover:bg-background/66 border-none w-full">
<SelectValue placeholder="Einheit" /> <SelectValue placeholder="Einheit" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <SelectGroup>
<SelectItem v-for="unit in units" :value="unit"> <SelectItem v-for="unit in units" :value="unit.id">
{{ unit }} {{ unit.name }}
</SelectItem> </SelectItem>
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
+7 -5
View File
@@ -294,7 +294,8 @@ export interface LineItem {
title: string, title: string,
description: string, description: string,
quantity: number, quantity: number,
unit: string, unitId: number,
unit: Unit,
price: number, price: number,
} }
@@ -310,7 +311,8 @@ export function newLineItem(isSection: boolean): LineItem {
title: '', title: '',
description: '', description: '',
quantity: 0, quantity: 0,
unit: 'Stunden', unitId: 2,
unit: newUnit(),
price: 93.75, price: 93.75,
} }
} }
@@ -361,9 +363,9 @@ export interface Unit {
export function newUnit(): Unit { export function newUnit(): Unit {
return { return {
id: 0, id: 2,
name: '', name: 'Stunden',
symbol: null, symbol: 'h',
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date() updatedAt: new Date()
} }
+3 -3
View File
@@ -54,9 +54,9 @@
<address> <address>
<div class="sender"> <div class="sender">
{{ $companyname }}, {{ $companyName }},
{{ $companyAddress['lineOne'] }}, {{ $companyAddress['lineOne'] }},
@if($companyAddress['lineOne']) @if($companyAddress['lineTwo'])
{{ $companyAddress['lineTwo'] }} {{ $companyAddress['lineTwo'] }}
@endif @endif
{{ $companyAddress['postalCode'] }} {{ $companyAddress['postalCode'] }}
@@ -170,7 +170,7 @@
{{ $item['description'] }} {{ $item['description'] }}
</td> </td>
<td>@toCommaFloat($item['quantity'])</td> <td>@toCommaFloat($item['quantity'])</td>
<td>{{ $item['unit'] }}</td> <td>{{ $item['unit']['name'] }}</td>
<td>@toCurrency($item['price'])</td> <td>@toCurrency($item['price'])</td>
<td>@toCurrency($item['quantity'] * $item['price'])</td> <td>@toCurrency($item['quantity'] * $item['price'])</td>
</tr> </tr>
+4 -1
View File
@@ -9,6 +9,7 @@
use App\Http\Controllers\ProductController; use App\Http\Controllers\ProductController;
use App\Http\Controllers\SettingController; use App\Http\Controllers\SettingController;
use App\Http\Controllers\TodoController; use App\Http\Controllers\TodoController;
use App\Http\Controllers\UnitController;
use App\Mail\OrderConfirmation; use App\Mail\OrderConfirmation;
use App\Services\CaldavService; use App\Services\CaldavService;
@@ -46,7 +47,7 @@
Route::get('/invoices/{id}/remind', [InvoiceController::class, 'remind']); Route::get('/invoices/{id}/remind', [InvoiceController::class, 'remind']);
Route::get('/lineitems/{invoiceId}', [LineItemController::class, 'index']); Route::get('/lineitems/{invoiceId}', [LineItemController::class, 'index']);
Route::post('/lineitems/import/{invoiceId}', [LineItemController::class, 'importFromCsv']);
Route::get('/offers/{id}/confirm', function ($id) { Route::get('/offers/{id}/confirm', function ($id) {
// $offer = offerController::single($id); // $offer = offerController::single($id);
@@ -96,3 +97,5 @@
Route::get('/settings', [SettingController::class, 'index']); Route::get('/settings', [SettingController::class, 'index']);
Route::post('/settings', [SettingController::class, 'update']); Route::post('/settings', [SettingController::class, 'update']);
Route::apiResource('/units', UnitController::class);