diff --git a/app/Http/Controllers/InvoiceController.php b/app/Http/Controllers/InvoiceController.php index 9670023..7b3f725 100644 --- a/app/Http/Controllers/InvoiceController.php +++ b/app/Http/Controllers/InvoiceController.php @@ -1,10 +1,12 @@ findOrFail($id); $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(); unset($invoiceArray['customer']['payment_terms_id']); @@ -211,7 +223,9 @@ public function exportPdf($id) 'isPDF' => true, 'fontPath' => public_path('storage/fonts/'), 'giroCode' => $giroCode, - 'totalPages' => 1 + 'totalPages' => 1, + 'companyName' => Setting::get('company.name'), + 'companyAddress' => json_decode(Setting::get('company.address'), true) ]; $options = [ @@ -476,7 +490,7 @@ public function store(Request $request) 'items.*.title' => 'nullable|string', 'items.*.description' => 'nullable|string', 'items.*.quantity' => 'nullable|numeric', - 'items.*.unit' => 'nullable|string', + 'items.*.unitId' => 'nullable|integer', 'items.*.price' => 'nullable|numeric', ]); @@ -563,7 +577,7 @@ public function update(Request $request, $id) 'items.*.title' => 'nullable|string', 'items.*.description' => 'nullable|string', 'items.*.quantity' => 'nullable|numeric', - 'items.*.unit' => 'nullable|string', + 'items.*.unitId' => 'nullable|integer', 'items.*.price' => 'nullable|numeric', ]); @@ -594,8 +608,8 @@ public function update(Request $request, $id) if (!empty($snakeCaseData['items'])) { foreach ($snakeCaseData['items'] as $item) { // Remove unit from item data if it's empty to let the database use the default value - if (empty($item['unit'])) { - unset($item['unit']); + if (empty($item['unitId'])) { + unset($item['unitId']); } if (isset($item['id']) && $item['id'] > 0) { // Update existing item diff --git a/app/Http/Controllers/LineItemController.php b/app/Http/Controllers/LineItemController.php index 219e1c2..e1ee58f 100644 --- a/app/Http/Controllers/LineItemController.php +++ b/app/Http/Controllers/LineItemController.php @@ -3,7 +3,9 @@ namespace App\Http\Controllers; use App\Models\LineItem; +use App\Models\Unit; use App\Support\ApiDataTransformer; +use Illuminate\Http\Request; class LineItemController extends Controller { @@ -26,4 +28,177 @@ public function index($invoiceId) 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; + } } diff --git a/app/Http/Controllers/UnitController.php b/app/Http/Controllers/UnitController.php new file mode 100644 index 0000000..f0ca790 --- /dev/null +++ b/app/Http/Controllers/UnitController.php @@ -0,0 +1,101 @@ +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' + ]); + } +} diff --git a/app/Support/ApiDataTransformer.php b/app/Support/ApiDataTransformer.php index 5e75884..d770d09 100644 --- a/app/Support/ApiDataTransformer.php +++ b/app/Support/ApiDataTransformer.php @@ -1,4 +1,5 @@ $value) { - $camelKey = self::convertSnakeToCamel($key); + $camelKey = lcfirst(str_replace(' ', '', ucwords(str_replace('_', ' ', $key)))); if (is_array($value)) { $result[$camelKey] = self::snakeToCamel($value); @@ -33,7 +34,7 @@ public static function camelToSnake(array $data): array $result = []; foreach ($data as $key => $value) { - $snakeKey = self::convertCamelToSnake($key); + $snakeKey = strtolower(preg_replace('/(?boolean('is_section')->default(false); $table->string('title')->nullable(); $table->string('description')->nullable(); - $table->integer('quantity')->default(1); + $table->decimal('quantity')->default(1); $table->foreignId('unit_id')->nullable()->constrained()->nullOnDelete(); $table->decimal('price', 10, 2)->nullable(); $table->timestamps(); + + $table->index('position'); + $table->index('invoice_id'); }); } diff --git a/database/seeders/UnitSeeder.php b/database/seeders/UnitSeeder.php index 94ae58b..3879e3b 100644 --- a/database/seeders/UnitSeeder.php +++ b/database/seeders/UnitSeeder.php @@ -9,7 +9,7 @@ class UnitSeeder extends Seeder { 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' => 'Tage', 'symbol' => 'd']); Unit::create(['name' => 'pauschal', 'symbol' => 'p']); diff --git a/resources/js/components/documents/DocumentTable.vue b/resources/js/components/documents/DocumentTable.vue index 4eae226..7684852 100644 --- a/resources/js/components/documents/DocumentTable.vue +++ b/resources/js/components/documents/DocumentTable.vue @@ -1,10 +1,10 @@