diff --git a/Layout/default/ManualInvoice/PDF_HEADER.html b/Layout/default/ManualInvoice/PDF_HEADER.html index 56b74016a..075f2c9bc 100644 --- a/Layout/default/ManualInvoice/PDF_HEADER.html +++ b/Layout/default/ManualInvoice/PDF_HEADER.html @@ -65,10 +65,12 @@
{{ addressLine_4 }}
{{ addressLine_5 }}
- - + - -
+ - +
+ QR-Code + @@ -87,16 +89,14 @@ + {{ leistungszeitraumHtml }} + {{ externeReferenzHtml }} {{ vatHtml }}
Belegdatum: {{ invoiceDate }}
- QR-Code -
diff --git a/Layout/default/ManualInvoice/PDF_MAIN.php b/Layout/default/ManualInvoice/PDF_MAIN.php index 9355ae1f2..49ef1d468 100644 --- a/Layout/default/ManualInvoice/PDF_MAIN.php +++ b/Layout/default/ManualInvoice/PDF_MAIN.php @@ -8,6 +8,43 @@ $net_total = $invoice->total; $gross_total = $invoice->total_gross; $is_credit = $net_total < 0; +// Check if any position has a discount to conditionally show the discount column +$hasDiscount = false; +foreach($invoice->positions as $p) { + if (($p->discount ?? 0) > 0) { + $hasDiscount = true; + break; + } +} + +$gesamtrabatt = $invoice->gesamtrabatt ?? 0; +$subtotal = 0; +foreach($invoice->positions as $p) { + $subtotal += $p->price_total ?? 0; +} + +// Group positions by position_group +$groupedPositions = []; +$hasGroups = false; +foreach($invoice->positions as $p) { + if (!empty($p->position_group)) { + $hasGroups = true; + } +} + +// If no positions have groups, put all in default (no group header will be shown) +if (!$hasGroups) { + $groupedPositions['_default'] = $invoice->positions; +} else { + foreach($invoice->positions as $p) { + $group = $p->position_group ?? 'Sonstige'; + if (!isset($groupedPositions[$group])) { + $groupedPositions[$group] = []; + } + $groupedPositions[$group][] = $p; + } +} + $this->setReturnValue(['filename' => $invoice->invoice_number . ".pdf"]); ?> @@ -48,12 +85,18 @@ $this->setReturnValue(['filename' => $invoice->invoice_number . ".pdf"]); height: 28px; } - #invoiceTable tr *:nth-child(5), + #invoiceTable tr *:nth-child(2), #invoiceTable tr *:nth-child(4), - #invoiceTable tr *:nth-child(3) { + #invoiceTable tr *:nth-child(5), + #invoiceTable tr *:nth-child(6), + #invoiceTable tr *:nth-child(7) { text-align: right; } + #invoiceTable tr *:nth-child(3) { + text-align: center; + } + #invoiceTable tr *:not(:first-child) { padding: 4px 0; } @@ -72,7 +115,7 @@ $this->setReturnValue(['filename' => $invoice->invoice_number . ".pdf"]); } #invoiceTable tr td:first-child { - max-width: 200pt; + max-width: 280pt; } @@ -84,88 +127,77 @@ $this->setReturnValue(['filename' => $invoice->invoice_number . ".pdf"]);

Ihre Xinon vom invoice_date)?>

+ einleitender_text ?? ''): ?> +

einleitender_text))?>

+ + - + positions as $p): - $timerange_month_only = method_exists($p, 'getOption') ? $p->getOption('timerange_month_only') : (isset($p->options) ? (json_decode($p->options, true)['timerange_month_only'] ?? false) : false); - - // Handle dates safely - $start_date = null; - $end_date = null; - if (!empty($p->start_date)) { - try { - $start_date = new DateTime($p->start_date); - } catch (Exception $e) { - $start_date = null; - } - } - if (!empty($p->end_date)) { - try { - $end_date = new DateTime($p->end_date); - } catch (Exception $e) { - $end_date = $start_date; - } - } else { - $end_date = $start_date; - } - + foreach($groupedPositions as $groupName => $positions): + ?> + + + + + + + amount ?? 0, 3, ",", "."); + $unit = htmlspecialchars($p->unit ?? 'Stk.'); $price = number_format($p->price ?? 0, 2, ",","."); + $discount = $p->discount ?? 0; $price_total = number_format($p->price_total ?? 0, 2, ",","."); $price_gross = number_format($p->price_gross ?? 0, 2, ",","."); $vatrate = number_format($p->vatrate ?? 0, 0, ",","."); - ?> "> - - - - - - - + + + + + + - - + 0): ?> + + + + + + + + + + + @@ -173,7 +205,7 @@ $this->setReturnValue(['filename' => $invoice->invoice_number . ".pdf"]); 0): ?> - + @@ -182,7 +214,7 @@ $this->setReturnValue(['filename' => $invoice->invoice_number . ".pdf"]); - +
Leistung / ProduktZeitraum Preis MengeRabatt % Netto € Ust. % Brutto €
+ +
+ product_name ?? '')?> + product_info) && $p->product_info): ?> +
product_info)?>
+ matchcode) && $p->matchcode): ?> -
matchcode)?>
+
matchcode)?>
- - - format("m.Y")?> - billing_period) && $p->billing_period > 1): ?> - format("m.Y")?> - format("m.Y") ?> - - format("d.m.Y") == $end_date->format("d.m.Y")): ?> - format("d.m.Y")?> - - format("d.m.Y")?> - format("d.m.Y") ?> - - - - format("d.m.Y")?> - - - - - - % %%
Gesamt Netto:
Zwischensumme:
Gesamtrabatt %:-
Gesamt Netto:
USt. %:USt. %:
Gesamt Brutto:Gesamt Brutto:
@@ -193,14 +225,31 @@ $this->setReturnValue(['filename' => $invoice->invoice_number . ".pdf"]);

tax_text?>

-

Gutschrift! Bitte nicht überweisen.

+

Gutschrift! Bitte nicht überweisen.

billing_type == "sepa"): ?> -

BITTE NICHT EINZAHLEN, DER BETRAG WIRD AUTOMATISCH VON IHREM KONTO ABGEBUCHT !

+

BITTE NICHT EINZAHLEN – DER BETRAG WIRD AUTOMATISCH VON IHREM KONTO ABGEBUCHT!

- Bitte überweisen Sie den Rechnungsbetrag bis zum  invoice_date))->modify("+14 days")->format("d.m.Y")?> auf folgendes Konto:
- IBAN:
- BIC:

- Bitte geben Sie als Verwendungszweck unbedingt die Rechnungsnummer an, nur so können wir Ihre Zahlung eindeutig zuordnen +
+

+ Zahlungsinformationen: +

+

+ Bitte überweisen Sie den Rechnungsbetrag bis zum invoice_date))->modify("+14 days")->format("d.m.Y")?> auf folgendes Konto: +

+ + + + +
IBAN:
BIC:
Bank:
+
+

+ Verwendungszweck: invoice_number ?? "VORSCHAU"?> +

+

+ Wichtig: Bitte geben Sie den oben angeführten Verwendungszweck bei der Überweisung an, damit wir Ihre Zahlung eindeutig zuordnen können. +

+
+
diff --git a/application/ManualInvoice/ManualInvoiceController.php b/application/ManualInvoice/ManualInvoiceController.php index 6bac5e0e3..caa91d659 100644 --- a/application/ManualInvoice/ManualInvoiceController.php +++ b/application/ManualInvoice/ManualInvoiceController.php @@ -1,5 +1,8 @@ 'total', 'text' => 'Netto', 'table' => ['sortable' => true, 'formatter' => 'formatPrice']], ['key' => 'total_gross', 'text' => 'Brutto', 'table' => ['sortable' => true, 'formatter' => 'formatPrice']], ['key' => 'status', 'text' => 'Status', 'table' => ['filter' => 'select', 'filterOptions' => [ - ['value' => 'draft', 'text' => 'Entwurf'], - ['value' => 'finalized', 'text' => 'Finalisiert'], - ['value' => 'exported', 'text' => 'Exportiert'], + ['value' => 'erstellt', 'text' => 'Erstellt'], + ['value' => 'gesendet', 'text' => 'Gesendet'], + ['value' => 'exportiert', 'text' => 'Exportiert'], ]]], ['key' => 'billing_type', 'text' => 'Zahlungsart', 'table' => ['filter' => 'select', 'filterOptions' => [ ['value' => 'invoice', 'text' => 'Rechnung'], @@ -31,7 +34,9 @@ class ManualInvoiceController extends TTCrud ]; protected array $additionalActions = [ - ['key' => 'createGutschrift', 'title' => 'Gutschrift erstellen', 'class' => 'fas fa-file-invoice text-warning'] + ['key' => 'createGutschrift', 'title' => 'Gutschrift erstellen', 'class' => 'fas fa-file-invoice text-warning'], + ['key' => 'pdfPreview', 'title' => 'PDF Vorschau', 'class' => 'fas fa-file-pdf text-danger'], + ['key' => 'sendInvoice', 'title' => 'Rechnung aussenden', 'class' => 'fas fa-paper-plane text-success'] ]; protected function createPDFAction($returnFilename = false) { @@ -46,9 +51,23 @@ class ManualInvoiceController extends TTCrud 'company' => '', 'firstname' => '', 'lastname' => '', 'street' => '', 'zip' => '', 'city' => '', 'country' => 'Österreich', 'email' => '', 'uid' => '', 'tax_text' => '', 'billing_type' => 'invoice', + 'leistungszeitraum' => '', 'einleitender_text' => '', 'externe_referenz' => '', 'gesamtrabatt' => 0, 'total' => 0, 'total_gross' => 0 ], $post); - $positions = array_map(fn($p) => (object)$p, $post['positions'] ?? []); + + // Convert invoice_date from string to timestamp if needed + if (isset($invoice->invoice_date) && is_string($invoice->invoice_date)) { + $invoice->invoice_date = strtotime($invoice->invoice_date); + } + + $positions = array_map(function($p) { + $obj = (object)$p; + // Map _group to position_group for preview + if (isset($p['_group'])) { + $obj->position_group = $p['_group']; + } + return $obj; + }, $post['positions'] ?? []); } else { $id = $this->request->id ?? $post['id'] ?? null; if (!$id || !($invoice = ManualInvoiceModel::get($id))) { @@ -86,6 +105,8 @@ class ManualInvoiceController extends TTCrud "{{ billingAccount }}" => $invoice->fibu_account_number ?? '', "{{ invoiceNumber }}" => $invoice->invoice_number ?? "VORSCHAU", "{{ invoiceDate }}" => date("d.m.Y", $invoice->invoice_date ?? time()), + "{{ leistungszeitraumHtml }}" => ($invoice->leistungszeitraum ?? '') ? "Leistungszeitraum:" . htmlspecialchars($invoice->leistungszeitraum) . "" : "", + "{{ externeReferenzHtml }}" => ($invoice->externe_referenz ?? '') ? "Externe Referenz:" . htmlspecialchars($invoice->externe_referenz) . "" : "", "{{ vatHtml }}" => ($invoice->uid ?? '') ? "Ihre UID:" . $invoice->uid . "" : "", "{{ qrCodeSrc }}" => $this->generateSepaQRCode($invoice->invoice_number ?? "VORSCHAU", round($invoice->total_gross ?? 0, 2)) ]; @@ -134,6 +155,186 @@ class ManualInvoiceController extends TTCrud exit; } + protected function pdfPreviewAction() { + $post = json_decode(file_get_contents('php://input'), true); + $id = $post['id'] ?? null; + + if (!$id || !($invoice = ManualInvoiceModel::get($id))) { + self::returnJson(['success' => false, 'message' => 'Rechnung nicht gefunden']); + return; + } + + // Log PDF preview in journal + $me = new User(); + $me->loadMe(); + ManualInvoiceJournalModel::create([ + 'manualinvoiceId' => $id, + 'text' => 'PDF Vorschau geöffnet', + 'createBy' => $me->id, + 'create' => time() + ]); + + // Return URL to open in new tab + $url = "?action=ManualInvoice_createPDF&id=" . $id; + self::returnJson(['success' => true, 'url' => $url]); + } + + protected function getInvoiceEmailAction() { + $post = json_decode(file_get_contents('php://input'), true); + $id = $post['id'] ?? null; + + if (!$id || !($invoice = ManualInvoiceModel::get($id))) { + self::returnJson(['success' => false, 'message' => 'Rechnung nicht gefunden']); + return; + } + + self::returnJson([ + 'success' => true, + 'invoice' => [ + 'id' => $invoice->id, + 'invoice_number' => $invoice->invoice_number, + 'email' => $invoice->email, + 'customerName' => trim(($invoice->company ?: '') . ' ' . $invoice->firstname . ' ' . $invoice->lastname) + ] + ]); + } + + protected function sendInvoiceEmailAction() { + // Enable error reporting for debugging + error_reporting(E_ALL); + ini_set('display_errors', 1); + ini_set('display_startup_errors', 1); + + $post = json_decode(file_get_contents('php://input'), true); + $id = $post['id'] ?? null; + $recipientEmail = $post['email'] ?? null; + $subject = $post['subject'] ?? 'Ihre Rechnung von XINON GmbH'; + $bodyText = $post['body'] ?? 'Sehr geehrte Damen und Herren,\n\nanbei erhalten Sie Ihre Rechnung.\n\nMit freundlichen Grüßen\nIhr Xinon Team'; + + if (!$id || !$recipientEmail) { + self::returnJson(['success' => false, 'message' => 'ID oder E-Mail-Adresse fehlt']); + return; + } + + $invoice = ManualInvoiceModel::get($id); + if (!$invoice) { + self::returnJson(['success' => false, 'message' => 'Rechnung nicht gefunden']); + return; + } + + // Generate PDF + $pdf_filename = $this->createPDFAction(true); + if (!$pdf_filename || !file_exists($pdf_filename)) { + self::returnJson(['success' => false, 'message' => 'PDF konnte nicht erstellt werden']); + return; + } + + $pdfContent = file_get_contents($pdf_filename); + + // --- HTML Email Generation --- + $logoToolPath = BASEDIR . '/public/assets/images/the-tool-logo.png'; + $logoXinonPath = BASEDIR . '/public/assets/images/xinon-full.png'; + $logoToolExists = file_exists($logoToolPath); + $logoXinonExists = file_exists($logoXinonPath); + + // Construct HTML Body + $html = 'Rechnung'; + $html .= '
'; + + // Logos + $html .= '
'; + if ($logoToolExists) $html .= 'The Tool'; + if ($logoXinonExists) $html .= 'Xinon'; + $html .= '
'; + + $html .= '

' . htmlspecialchars($subject) . '

'; + $html .= '
'; + $html .= nl2br(htmlspecialchars($bodyText)); + $html .= '
'; + + $html .= '
'; + $html .= 'XINON GmbH | www.xinon.at'; + $html .= '
'; + + $mail = new PHPMailer(true); + try { + // Server settings + $mail->isSMTP(); + $mail->Host = TT_PIPEWORK_SMTP_HOST; + $mail->SMTPAuth = true; + $mail->Username = TT_PIPEWORK_SMTP_USER; + $mail->Password = TT_PIPEWORK_SMTP_PASS; + $mail->CharSet = PHPMailer::CHARSET_UTF8; + $mail->Encoding = 'base64'; + $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; + $mail->Port = 587; + + // Logos + if ($logoToolExists) $mail->addEmbeddedImage($logoToolPath, 'logo_thetool'); + if ($logoXinonExists) $mail->addEmbeddedImage($logoXinonPath, 'logo_xinon'); + + $mail->addReplyTo('backoffice@xinon.at', 'XINON Backoffice'); + $mail->setFrom('thetool@xinon.at', 'XINON TheTool'); + + $customerName = trim(($invoice->company ?: '') . ' ' . $invoice->firstname . ' ' . $invoice->lastname); + $mail->addAddress($recipientEmail, $customerName); + $mail->Subject = $subject; + $mail->isHTML(true); + $mail->Body = $html; + $mail->AltBody = strip_tags($bodyText); + + $mail->addStringAttachment($pdfContent, $invoice->invoice_number . '_Rechnung.pdf', 'base64', 'application/pdf'); + + $mail->send(); + + // Update invoice status + $invoice->status = 'gesendet'; + $invoice->save(); + + // Add Journal Entry + $me = new User(); + $me->loadMe(); + ManualInvoiceJournalModel::create([ + 'manualinvoiceId' => $id, + 'text' => "Rechnung per E-Mail an $recipientEmail gesendet.", + 'statusChange' => 'gesendet', + 'createBy' => $me->id, + 'create' => time() + ]); + + self::returnJson(['success' => true, 'message' => 'E-Mail erfolgreich versendet an ' . $recipientEmail]); + } catch (Exception $e) { + self::returnJson(['success' => false, 'message' => 'E-Mail konnte nicht gesendet werden. Fehler: ' . $mail->ErrorInfo]); + } + } + + protected function downloadInvoiceAction() { + $post = json_decode(file_get_contents('php://input'), true); + $id = $post['id'] ?? null; + + if (!$id || !($invoice = ManualInvoiceModel::get($id))) { + self::returnJson(['success' => false, 'message' => 'Rechnung nicht gefunden']); + return; + } + + $me = new User(); + $me->loadMe(); + + // Log download in journal + ManualInvoiceJournalModel::create([ + 'manualinvoiceId' => $id, + 'text' => 'Rechnung heruntergeladen', + 'createBy' => $me->id, + 'create' => time() + ]); + + $downloadUrl = "?action=ManualInvoice_downloadInvoicePdf&id=" . $id; + self::returnJson([ + 'success' => true, + 'url' => $downloadUrl + ]); + } + protected function beforeCreate(&$data): bool { if (isset($data['positions']) && is_array($data['positions'])) { $this->tempPositions = $data['positions']; @@ -143,12 +344,18 @@ class ManualInvoiceController extends TTCrud $me = new User(); $me->loadMe(); + // Convert invoice_date from string to timestamp if needed + if (isset($data['invoice_date']) && is_string($data['invoice_date'])) { + $data['invoice_date'] = strtotime($data['invoice_date']); + } + $data = array_merge([ 'invoice_number' => ManualInvoiceModel::getNextInvoiceNumber(), - 'invoice_date' => time(), - 'status' => 'draft', + 'invoice_date' => $data['invoice_date'] ?? time(), + 'status' => 'erstellt', 'fibu_payment_skonto' => 0, 'fibu_payment_skonto_rate' => 0, + 'gesamtrabatt' => 0, 'total' => 0, 'total_gross' => 0, 'create_by' => $me->id, @@ -163,6 +370,17 @@ class ManualInvoiceController extends TTCrud protected function afterCreate($data) { $this->savePositions($data['id']); $this->recalculateTotals($data['id']); + + // Log creation in journal + $me = new User(); + $me->loadMe(); + ManualInvoiceJournalModel::create([ + 'manualinvoiceId' => $data['id'], + 'text' => 'Rechnung erstellt', + 'statusChange' => 'erstellt', + 'createBy' => $me->id, + 'create' => time() + ]); } protected function beforeUpdate(&$data): bool { @@ -171,11 +389,16 @@ class ManualInvoiceController extends TTCrud unset($data['positions']); } - if (isset($data['id']) && ($invoice = ManualInvoiceModel::get($data['id'])) && $invoice->status === 'exported') { + if (isset($data['id']) && ($invoice = ManualInvoiceModel::get($data['id'])) && $invoice->status === 'exportiert') { $this->infoMessages['update'] = 'Rechnung wurde bereits exportiert und kann nicht mehr bearbeitet werden'; return false; } + // Convert invoice_date from string to timestamp if needed + if (isset($data['invoice_date']) && is_string($data['invoice_date'])) { + $data['invoice_date'] = strtotime($data['invoice_date']); + } + $me = new User(); $me->loadMe(); $data['edit_by'] = $me->id; @@ -190,6 +413,16 @@ class ManualInvoiceController extends TTCrud $this->savePositions($data['id']); $this->recalculateTotals($data['id']); + + // Log update in journal + $me = new User(); + $me->loadMe(); + ManualInvoiceJournalModel::create([ + 'manualinvoiceId' => $data['id'], + 'text' => 'Rechnung aktualisiert', + 'createBy' => $me->id, + 'create' => time() + ]); } private function savePositions($invoiceId) { @@ -199,9 +432,18 @@ class ManualInvoiceController extends TTCrud $me->loadMe(); foreach ($this->tempPositions as $position) { + // Skip empty positions + if (empty($position['product_name']) || ($position['amount'] ?? 0) == 0) continue; + + // Map _group to position_group + $groupName = $position['_group'] ?? null; + unset($position['_group']); + ManualInvoicepositionModel::create(array_merge([ 'manualinvoice_id' => $invoiceId, - 'start_date' => date('Y-m-d'), + 'position_group' => $groupName, + 'unit' => 'Stk.', + 'discount' => 0, 'create_by' => $me->id, 'edit_by' => $me->id, 'create' => time(), @@ -215,8 +457,23 @@ class ManualInvoiceController extends TTCrud if (!($invoice = ManualInvoiceModel::get($invoiceId))) return; $positions = ManualInvoicepositionModel::search(['manualinvoice_id' => $invoiceId]); - $invoice->total = array_sum(array_column($positions, 'price_total')); - $invoice->total_gross = array_sum(array_column($positions, 'price_gross')); + $subtotal = array_sum(array_column($positions, 'price_total')); + + // Apply gesamtrabatt (total discount) if exists + $gesamtrabatt = $invoice->gesamtrabatt ?? 0; + $discountAmount = $subtotal * ($gesamtrabatt / 100); + $netTotal = $subtotal - $discountAmount; + + // Calculate gross total with VAT applied after discount + $grossTotal = 0; + foreach ($positions as $pos) { + $positionNet = $pos->price_total; + $positionAfterDiscount = $positionNet * (1 - $gesamtrabatt / 100); + $grossTotal += $positionAfterDiscount * (1 + $pos->vatrate / 100); + } + + $invoice->total = $netTotal; + $invoice->total_gross = $grossTotal; $invoice->save(); } @@ -272,23 +529,23 @@ class ManualInvoiceController extends TTCrud return [ 'id' => $pos->id, 'manualinvoice_id' => $pos->manualinvoice_id, + '_group' => $pos->position_group ?? '', 'billing_id' => $pos->billing_id, 'contract_id' => $pos->contract_id, - 'start_date' => $pos->start_date, - 'end_date' => $pos->end_date, 'matchcode' => $pos->matchcode, 'product_id' => $pos->product_id, 'product_name' => $pos->product_name, 'product_info' => $pos->product_info, 'amount' => $pos->amount, + 'unit' => $pos->unit ?? 'Stk.', 'price' => $pos->price, + 'discount' => $pos->discount ?? 0, 'price_total' => $pos->price_total, 'price_gross' => $pos->price_gross, 'vatrate' => $pos->vatrate, 'fibu_cost_account' => $pos->fibu_cost_account, 'fibu_cost_account_legacy' => $pos->fibu_cost_account_legacy, 'fibu_taxcode' => $pos->fibu_taxcode, - 'billing_period' => $pos->billing_period, 'options' => $pos->options ]; } @@ -340,6 +597,7 @@ class ManualInvoiceController extends TTCrud 'original_amount' => $pos->amount, 'credited_amount' => $creditedAmounts[$key] ?? 0, 'available_amount' => $availableAmount, + 'unit' => $pos->unit ?? 'Stk.', 'price' => $pos->price, 'vatrate' => $pos->vatrate, 'product_id' => $pos->product_id, @@ -376,6 +634,10 @@ class ManualInvoiceController extends TTCrud $invoiceData = [ 'invoice_number' => ManualInvoiceModel::getNextInvoiceNumber(), 'invoice_date' => time(), + 'leistungszeitraum' => $originalInvoice->leistungszeitraum ?? null, + 'einleitender_text' => 'Gutschrift zur Rechnung ' . $originalInvoice->invoice_number, + 'externe_referenz' => $originalInvoice->externe_referenz ?? null, + 'gesamtrabatt' => 0, 'owner_id' => $originalInvoice->owner_id, 'billingaddress_id' => $originalInvoice->billingaddress_id, 'customer_number' => $originalInvoice->customer_number, @@ -410,7 +672,7 @@ class ManualInvoiceController extends TTCrud 'total_gross' => 0, 'vatgroup_id' => $originalInvoice->vatgroup_id, 'credit_for_invoice_id' => $originalInvoiceId, - 'status' => 'finalized', + 'status' => 'erstellt', 'create' => time(), 'edit' => time(), 'create_by' => $me->id, @@ -425,11 +687,14 @@ class ManualInvoiceController extends TTCrud $priceTotal = (-abs($pos['amount'])) * $pos['price']; ManualInvoicepositionModel::create([ 'manualinvoice_id' => $creditInvoiceId, + 'position_group' => null, 'product_id' => $pos['product_id'], 'product_name' => $pos['product_name'], 'product_info' => $pos['product_info'] ?? '', 'amount' => -abs($pos['amount']), + 'unit' => $pos['unit'] ?? 'Stk.', 'price' => $pos['price'], + 'discount' => 0, 'vatrate' => $pos['vatrate'], 'price_total' => $priceTotal, 'price_gross' => $priceTotal * (1 + $pos['vatrate'] / 100), @@ -437,8 +702,7 @@ class ManualInvoiceController extends TTCrud 'fibu_cost_account' => $pos['fibu_cost_account'] ?? null, 'fibu_taxcode' => $pos['fibu_taxcode'] ?? null, 'contract_id' => 0, - 'start_date' => date('Y-m-d'), - 'billing_period' => 0, + 'billing_id' => null, 'create_by' => $me->id, 'edit_by' => $me->id, 'create' => time(), diff --git a/application/ManualInvoice/ManualInvoiceModel.php b/application/ManualInvoice/ManualInvoiceModel.php index 61e82f7f6..77408527e 100644 --- a/application/ManualInvoice/ManualInvoiceModel.php +++ b/application/ManualInvoice/ManualInvoiceModel.php @@ -4,6 +4,10 @@ class ManualInvoiceModel extends TTCrudBaseModel { public int $id; public ?string $invoice_number; public int $invoice_date; + public ?string $leistungszeitraum; + public ?string $einleitender_text; + public ?string $externe_referenz; + public float $gesamtrabatt; public int $owner_id; public int $billingaddress_id; public int $customer_number; @@ -51,13 +55,13 @@ class ManualInvoiceModel extends TTCrudBaseModel { $last = $invoices[0]->invoice_number ?? null; $year = date("Y"); - if ($last && preg_match('/^MRN(\d+)-X(\d+)$/', $last, $m)) { + if ($last && preg_match('/^RN(\d+)-C(\d+)$/', $last, $m)) { $num = ($m[1] == $year) ? $m[2] + 1 : 1; } else { $num = 1; } - return sprintf("MRN%s-X%06d", $year, $num); + return sprintf("RN%s-C%06d", $year, $num); } public function getProperty($name) { diff --git a/application/ManualInvoiceJournal/ManualInvoiceJournalModel.php b/application/ManualInvoiceJournal/ManualInvoiceJournalModel.php new file mode 100644 index 000000000..bb483b8b2 --- /dev/null +++ b/application/ManualInvoiceJournal/ManualInvoiceJournalModel.php @@ -0,0 +1,12 @@ +getEnvironment() == "thetool") { + $table = $this->table("ManualInvoice"); + $table->addColumn("leistungszeitraum", "string", ["null" => true, "default" => null, "length" => 255, "after" => "invoice_date"]); + $table->addColumn("einleitender_text", "text", ["null" => true, "default" => null, "after" => "leistungszeitraum"]); + $table->addColumn("externe_referenz", "string", ["null" => true, "default" => null, "length" => 255, "after" => "einleitender_text"]); + $table->addColumn("gesamtrabatt", "decimal", ["null" => false, "default" => 0, "precision" => 6, "scale" => 2, "after" => "externe_referenz"]); + $table->save(); + + $positionTable = $this->table("ManualInvoiceposition"); + $positionTable->addColumn("discount", "decimal", ["null" => false, "default" => 0, "precision" => 6, "scale" => 2, "after" => "price"]); + $positionTable->save(); + } + } + + public function down(): void + { + if($this->getEnvironment() == "thetool") { + $this->table("ManualInvoiceposition")->removeColumn("discount")->save(); + $this->table("ManualInvoice") + ->removeColumn("leistungszeitraum") + ->removeColumn("einleitender_text") + ->removeColumn("externe_referenz") + ->removeColumn("gesamtrabatt") + ->save(); + } + } +} diff --git a/db/migrations/20251204000001_update_manualinvoiceposition_structure.php b/db/migrations/20251204000001_update_manualinvoiceposition_structure.php new file mode 100644 index 000000000..678f3f3aa --- /dev/null +++ b/db/migrations/20251204000001_update_manualinvoiceposition_structure.php @@ -0,0 +1,37 @@ +getEnvironment() == "thetool") { + $table = $this->table("ManualInvoiceposition"); + + $table->addColumn("position_group", "string", ["null" => true, "default" => null, "length" => 255, "after" => "manualinvoice_id"]); + + $table->removeColumn("start_date"); + $table->removeColumn("end_date"); + $table->removeColumn("billing_period"); + + $table->save(); + } + } + + public function down(): void + { + if($this->getEnvironment() == "thetool") { + $table = $this->table("ManualInvoiceposition"); + + $table->addColumn("start_date", "date", ["null" => false, "after" => "contract_id"]); + $table->addColumn("end_date", "date", ["null" => true, "default" => null, "after" => "start_date"]); + $table->addColumn("billing_period", "integer", ["null" => false, "default" => 0, "after" => "fibu_taxcode"]); + + $table->removeColumn("position_group"); + + $table->save(); + } + } +} diff --git a/db/migrations/20251204000002_add_unit_to_manualinvoiceposition.php b/db/migrations/20251204000002_add_unit_to_manualinvoiceposition.php new file mode 100644 index 000000000..dd3dc877e --- /dev/null +++ b/db/migrations/20251204000002_add_unit_to_manualinvoiceposition.php @@ -0,0 +1,30 @@ +getEnvironment() == "thetool") { + $table = $this->table("ManualInvoiceposition"); + + $table->addColumn("unit", "string", [ + "null" => false, + "default" => "Stk.", + "length" => 10, + "after" => "amount" + ]); + + $table->save(); + } + } + + public function down(): void + { + if($this->getEnvironment() == "thetool") { + $this->table("ManualInvoiceposition")->removeColumn("unit")->save(); + } + } +} diff --git a/db/migrations/20251204000003_create_manual_invoice_journal.php b/db/migrations/20251204000003_create_manual_invoice_journal.php new file mode 100644 index 000000000..0a9260967 --- /dev/null +++ b/db/migrations/20251204000003_create_manual_invoice_journal.php @@ -0,0 +1,24 @@ +getEnvironment() == "thetool") { + $table = $this->table('ManualInvoiceJournal'); + $table->addColumn('manualinvoiceId', 'integer', ['signed' => false]) + ->addColumn('text', 'text', ['null' => true]) + ->addColumn('data', 'json', ['null' => true]) + ->addColumn('statusChange', 'string', ['limit' => 255, 'null' => true]) + ->addColumn('fileIds', 'json', ['null' => true]) + ->addColumn('createBy', 'integer', ['signed' => false]) + ->addColumn('create', 'integer') + ->addForeignKey('manualinvoiceId', 'ManualInvoice', 'id', ['delete' => 'CASCADE', 'update' => 'CASCADE']) + ->addIndex(['manualinvoiceId'], ['name' => 'manualinvoiceId_idx']) + ->create(); + } + } +} diff --git a/db/migrations/20251204000004_update_manual_invoice_status_values.php b/db/migrations/20251204000004_update_manual_invoice_status_values.php new file mode 100644 index 000000000..47725e8bc --- /dev/null +++ b/db/migrations/20251204000004_update_manual_invoice_status_values.php @@ -0,0 +1,33 @@ +getEnvironment() == "thetool") { + // Change status column from enum to varchar to support new values + $this->execute("ALTER TABLE ManualInvoice MODIFY COLUMN status VARCHAR(50) NOT NULL DEFAULT 'erstellt'"); + + // Update existing values to new ones + $this->execute("UPDATE ManualInvoice SET status = 'erstellt' WHERE status = 'draft'"); + $this->execute("UPDATE ManualInvoice SET status = 'gesendet' WHERE status = 'finalized'"); + $this->execute("UPDATE ManualInvoice SET status = 'exportiert' WHERE status = 'exported'"); + } + } + + public function down(): void + { + if ($this->getEnvironment() == "thetool") { + // Revert values back + $this->execute("UPDATE ManualInvoice SET status = 'draft' WHERE status = 'erstellt'"); + $this->execute("UPDATE ManualInvoice SET status = 'finalized' WHERE status = 'gesendet'"); + $this->execute("UPDATE ManualInvoice SET status = 'exported' WHERE status = 'exportiert'"); + + // Change back to enum + $this->execute("ALTER TABLE ManualInvoice MODIFY COLUMN status ENUM('draft', 'finalized', 'exported') NOT NULL DEFAULT 'draft'"); + } + } +} diff --git a/public/js/pages/ManualInvoice/ManualInvoice.css b/public/js/pages/ManualInvoice/ManualInvoice.css index 0fc7a0ec2..7d7a726d9 100644 --- a/public/js/pages/ManualInvoice/ManualInvoice.css +++ b/public/js/pages/ManualInvoice/ManualInvoice.css @@ -270,4 +270,15 @@ .preview-footer .page-number { text-align: right; +} + +/* Fix tt-select label to match tt-input styling */ +.manual-invoice-overlay .tt-select-modern label.col-form-label { + font-weight: normal; + margin-bottom: 0.5rem; +} + +/* Ensure consistent form-group spacing */ +.manual-invoice-overlay .tt-select-modern.form-group { + margin-bottom: 1rem; } \ No newline at end of file diff --git a/public/js/pages/ManualInvoice/ManualInvoice.js b/public/js/pages/ManualInvoice/ManualInvoice.js index 2616cbf35..f81021b9c 100644 --- a/public/js/pages/ManualInvoice/ManualInvoice.js +++ b/public/js/pages/ManualInvoice/ManualInvoice.js @@ -4,7 +4,7 @@ Vue.component('manual-invoice', {
- + @@ -18,9 +18,10 @@ Vue.component('manual-invoice', { + `, - data: () => ({ isModalOpen: false, editingInvoiceData: null, isGutschriftModalOpen: false, gutschriftInvoiceId: null }), + data: () => ({ isModalOpen: false, editingInvoiceData: null, isGutschriftModalOpen: false, gutschriftInvoiceId: null, isSendModalOpen: false, sendInvoiceId: null }), methods: { openModal(invoice = null) { this.editingInvoiceData = invoice ? JSON.parse(JSON.stringify(invoice)) : null; @@ -36,17 +37,17 @@ Vue.component('manual-invoice', { const positions = invoiceData.positions.map(p => { const amount = parseFloat(p.amount) || 0; const price = parseFloat(p.price) || 0; + const discount = parseFloat(p.discount) || 0; const vatrate = parseFloat(p.vatrate) || 0; + const priceAfterDiscount = amount * price * (1 - discount / 100); return { - ...p, amount, price, vatrate, - price_total: amount * price, - price_gross: (amount * price) * (1 + vatrate / 100), + ...p, amount, price, discount, vatrate, + unit: p.unit || 'Stk.', + price_total: priceAfterDiscount, + price_gross: priceAfterDiscount * (1 + vatrate / 100), product_id: p.product_id || 0, contract_id: p.contract_id || 0, billing_id: p.billing_id || null, - billing_period: p.billing_period || 0, - start_date: p.start_date || moment().format('YYYY-MM-DD'), - end_date: p.end_date || null, matchcode: p.matchcode || null, fibu_cost_account: p.fibu_cost_account || null, fibu_cost_account_legacy: p.fibu_cost_account_legacy || null, @@ -66,7 +67,8 @@ Vue.component('manual-invoice', { billing_delivery: 'email', fibu_payment_due: 14, fibu_account_number: invoiceData.fibu_account_number || 0, - vatgroup_id: 1 + vatgroup_id: 1, + gesamtrabatt: parseFloat(invoiceData.gesamtrabatt) || 0 }; const url = invoiceData.id ? window.TT_CONFIG.UPDATE_URL : window.TT_CONFIG.CREATE_URL; @@ -93,8 +95,33 @@ Vue.component('manual-invoice', { }, closeGutschriftModal() { this.isGutschriftModalOpen = false; this.gutschriftInvoiceId = null; }, handleGutschriftCreated() { this.closeGutschriftModal(); this.$refs.table.$refs.table.refreshTable(); }, - getStatusClass(s) { return { 'draft': 'badge badge-secondary', 'finalized': 'badge badge-success', 'exported': 'badge badge-primary' }[s] || 'badge badge-secondary'; }, - getStatusText(s) { return { 'draft': 'Entwurf', 'finalized': 'Finalisiert', 'exported': 'Exportiert' }[s] || s; } + async handlePdfPreview(invoice) { + try { + const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/ManualInvoice/pdfPreview`, { id: invoice.id }); + if (data.success && data.url) { + window.open(data.url, '_blank'); + } else { + window.notify('error', data.message || 'Fehler beim Öffnen der PDF Vorschau'); + } + } catch (e) { + console.error('Error opening PDF preview:', e); + window.notify('error', 'Fehler: ' + (e.response?.data?.message || e.message)); + } + }, + handleSendInvoice(invoice) { + this.sendInvoiceId = invoice.id; + this.isSendModalOpen = true; + }, + closeSendModal() { + this.isSendModalOpen = false; + this.sendInvoiceId = null; + }, + handleInvoiceSent() { + this.closeSendModal(); + this.$refs.table.$refs.table.refreshTable(); + }, + getStatusClass(s) { return { 'erstellt': 'badge badge-secondary', 'gesendet': 'badge badge-success', 'exportiert': 'badge badge-primary' }[s] || 'badge badge-secondary'; }, + getStatusText(s) { return { 'erstellt': 'Erstellt', 'gesendet': 'Gesendet', 'exportiert': 'Exportiert' }[s] || s; } } }); @@ -117,15 +144,18 @@ Vue.component('manual-invoice-modal', {
- - +
+ + +
- + - + + @@ -150,20 +180,31 @@ Vue.component('manual-invoice-modal', { pdfPreviewUrl: '', previewDebounceTimer: null, invoiceData: { - id: null, invoice_number: `MRN${new Date().getFullYear()}-X000001`, invoice_date: moment().unix(), + id: null, invoice_number: null, invoice_date: moment().format('YYYY-MM-DD'), billingaddress_id: null, owner_id: null, customer_number: 0, fibu_account_number: 0, company: '', firstname: '', lastname: '', street: '', zip: '', city: '', country: 'Österreich', - uid: '', email: '', billing_type: 'invoice', tax_text: '', positions: [], total: 0, total_gross: 0 + uid: '', email: '', billing_type: 'invoice', tax_text: '', + leistungszeitraum: '', einleitender_text: '', externe_referenz: '', gesamtrabatt: 0, + positions: [], total: 0, total_gross: 0 }, billingTypeOptions: [{value: 'invoice', text: 'Rechnung'}, {value: 'sepa', text: 'SEPA'}], positionsConfig: { fields: { product_name: { type: 'input', label: 'Bezeichnung' }, product_info: { type: 'input', label: 'Zusatzinfo' }, - start_date: { type: 'input', label: 'Start', inputType: 'date' }, - end_date: { type: 'input', label: 'Ende', inputType: 'date' }, amount: { type: 'input', label: 'Menge', inputType: 'number' }, + unit: { + type: 'select', + label: 'Einheit', + options: [ + { value: 'Pau.', text: 'Pau.' }, + { value: 'Stk.', text: 'Stk.' }, + { value: 'h', text: 'h' }, + { value: 'm', text: 'm' } + ] + }, price: { type: 'input', label: 'Einzelpreis (€)', inputType: 'number' }, + discount: { type: 'input', label: 'Rabatt (%)', inputType: 'number' }, vatrate: { type: 'input', label: 'USt. (%)', inputType: 'number' }, }, validateForm: (d) => { @@ -178,14 +219,33 @@ Vue.component('manual-invoice-modal', { computed: { overlayClasses() { return { 'preview-active-small': !this.isLargeScreen && this.showPreviewOnSmallScreen, 'editor-active-small': !this.isLargeScreen && !this.showPreviewOnSmallScreen }; }, totals() { - let net = 0, vat = {}; + let subtotal = 0; (this.invoiceData.positions || []).forEach(p => { - const lineTotal = (parseFloat(p.amount) || 0) * (parseFloat(p.price) || 0); - const r = parseInt(p.vatrate) || 0; - net += lineTotal; - vat[r] = (vat[r] || 0) + lineTotal * (r / 100); + const amount = parseFloat(p.amount) || 0; + const price = parseFloat(p.price) || 0; + const discount = parseFloat(p.discount) || 0; + const lineTotal = amount * price * (1 - discount / 100); + subtotal += lineTotal; }); - return { net, vat, gross: net + Object.values(vat).reduce((a, b) => a + b, 0) }; + + // Apply gesamtrabatt + const gesamtrabatt = parseFloat(this.invoiceData.gesamtrabatt) || 0; + const net = subtotal * (1 - gesamtrabatt / 100); + + // Calculate VAT + let vat = {}, gross = 0; + (this.invoiceData.positions || []).forEach(p => { + const amount = parseFloat(p.amount) || 0; + const price = parseFloat(p.price) || 0; + const discount = parseFloat(p.discount) || 0; + const r = parseInt(p.vatrate) || 0; + const lineNet = amount * price * (1 - discount / 100) * (1 - gesamtrabatt / 100); + const lineVat = lineNet * (r / 100); + vat[r] = (vat[r] || 0) + lineVat; + gross += lineNet + lineVat; + }); + + return { subtotal, net, vat, gross }; } }, watch: { @@ -249,12 +309,16 @@ Vue.component('manual-invoice-modal', { async updatePdfPreview() { this.pdfLoading = true; try { - const positions = this.invoiceData.positions.map(p => { - const amount = parseFloat(p.amount) || 0; - const price = parseFloat(p.price) || 0; - const vatrate = parseFloat(p.vatrate) || 0; - return { ...p, amount, price, vatrate, price_total: amount * price, price_gross: (amount * price) * (1 + vatrate / 100) }; - }); + const positions = this.invoiceData.positions + .filter(p => p.product_name && (parseFloat(p.amount) || 0) > 0) // Filter out empty positions + .map(p => { + const amount = parseFloat(p.amount) || 0; + const price = parseFloat(p.price) || 0; + const discount = parseFloat(p.discount) || 0; + const vatrate = parseFloat(p.vatrate) || 0; + const priceAfterDiscount = amount * price * (1 - discount / 100); + return { ...p, amount, price, discount, vatrate, unit: p.unit || 'Stk.', price_total: priceAfterDiscount, price_gross: priceAfterDiscount * (1 + vatrate / 100) }; + }); const payload = { preview: true, ...this.invoiceData, @@ -349,4 +413,127 @@ Vue.component('gutschrift-modal', { close() { this.$emit('close'); }, formatPrice(v) { return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(v || 0); } } +}); + +Vue.component('send-invoice-modal', { + props: ['invoiceId'], + template: ` + + +
+ +
+
+
+ Rechnung: {{ invoice.invoice_number }}
+ Kunde: {{ invoice.customerName }} +
+
+
+ +
+ + +
+
+ + +
+
+
+ +
+
+
+ `, + data() { + return { + invoice: null, + loading: false, + selectedAction: 'email', + emailAddress: '' + }; + }, + computed: { + actionButtonText() { + return this.selectedAction === 'email' ? 'E-Mail versenden' : 'Herunterladen'; + } + }, + async mounted() { + try { + const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/ManualInvoice/getInvoiceEmail`, { id: this.invoiceId }); + if (data.success) { + this.invoice = data.invoice; + this.emailAddress = data.invoice.email || ''; + this.selectedAction = data.invoice.email ? 'email' : 'download'; + } else { + window.notify('error', data.message || 'Fehler beim Laden der Rechnung'); + this.close(); + } + } catch (e) { + window.notify('error', 'Fehler beim Laden der Rechnung'); + this.close(); + } + }, + methods: { + async handleAction() { + if (this.selectedAction === 'email') { + await this.sendEmail(); + } else { + await this.downloadPdf(); + } + }, + async sendEmail() { + if (!this.emailAddress) { + window.notify('error', 'Bitte E-Mail-Adresse eingeben'); + return; + } + this.loading = true; + try { + const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/ManualInvoice/sendInvoiceEmail`, { + id: this.invoiceId, + email: this.emailAddress + }); + if (data.success) { + window.notify('success', data.message); + this.$emit('sent'); + } else { + window.notify('error', data.message || 'Fehler beim Versenden'); + } + } catch (e) { + window.notify('error', 'Fehler: ' + (e.response?.data?.message || e.message)); + } finally { + this.loading = false; + } + }, + async downloadPdf() { + this.loading = true; + try { + const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/ManualInvoice/downloadInvoice`, { + id: this.invoiceId + }); + if (data.success && data.url) { + window.location.href = data.url; + this.$emit('sent'); + } else { + window.notify('error', data.message || 'Fehler beim Download'); + } + } catch (e) { + window.notify('error', 'Fehler: ' + (e.response?.data?.message || e.message)); + } finally { + this.loading = false; + } + }, + close() { + this.$emit('close'); + } + } }); \ No newline at end of file