Add discounts, fields, and PDF/email support to manual invoices.
This commit is contained in:
@@ -65,10 +65,12 @@
|
||||
<div>{{ addressLine_4 }}</div>
|
||||
<div>{{ addressLine_5 }}</div>
|
||||
</td>
|
||||
<td style="float: right">
|
||||
<table class="info-table">
|
||||
<td style="vertical-align: top; text-align: right;">
|
||||
<table style="display: inline-table; vertical-align: top;">
|
||||
<tr>
|
||||
<td></td>
|
||||
<td style="vertical-align: top; padding-right: 10px;">
|
||||
<img alt="QR-Code" src="{{ qrCodeSrc }}" style="display: block; height: 100%; max-height: 3.5cm; width: auto;">
|
||||
</td>
|
||||
<td>
|
||||
<table class="invoice-details">
|
||||
<tr>
|
||||
@@ -87,16 +89,14 @@
|
||||
<td>Belegdatum:</td>
|
||||
<td>{{ invoiceDate }}</td>
|
||||
</tr>
|
||||
{{ leistungszeitraumHtml }}
|
||||
{{ externeReferenzHtml }}
|
||||
{{ vatHtml }}
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
|
||||
<td style="float: right; vertical-align: top; margin-top: 0; padding-top: 0">
|
||||
<img alt="QR-Code" src="{{ qrCodeSrc }}" style="text-align:right;height: 3.5cm;">
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -84,88 +127,77 @@ $this->setReturnValue(['filename' => $invoice->invoice_number . ".pdf"]);
|
||||
|
||||
<h2 style="text-align: center;color: #005384">Ihre Xinon <?=($is_credit) ? "Gutschrift" : "Rechnung"?> vom <?=date("d.m.Y",$invoice->invoice_date)?></h2>
|
||||
|
||||
<?php if($invoice->einleitender_text ?? ''): ?>
|
||||
<p style="margin-top: 10pt; margin-bottom: 20pt; text-align: center; font-weight: bold;"><?=nl2br(htmlspecialchars($invoice->einleitender_text))?></p>
|
||||
<?php endif; ?>
|
||||
|
||||
<table style="border-collapse: collapse; width: 100%;" id="invoiceTable">
|
||||
<tr style="font-weight: bold; border-bottom: 1px solid black;" class="uneven">
|
||||
<th style="text-align: center">Leistung / Produkt</th>
|
||||
<th style="text-align: center">Zeitraum</th>
|
||||
<th style="text-align: right">Preis</th>
|
||||
<th style="text-align: center">Menge</th>
|
||||
<?php if($hasDiscount): ?><th style="text-align: right">Rabatt %</th><?php endif; ?>
|
||||
<th style="text-align: right">Netto €</th>
|
||||
<th style="text-align: right">Ust. %</th>
|
||||
<th style="text-align: right; padding-right: 4pt">Brutto €</th>
|
||||
</tr>
|
||||
<?php
|
||||
$i = 0;
|
||||
foreach($invoice->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):
|
||||
?>
|
||||
<!-- Group Header (only show if not default) -->
|
||||
<?php if ($groupName !== '_default'): ?>
|
||||
<tr style="background-color: #d9d9d9; font-weight: bold;">
|
||||
<td colspan="<?=$hasDiscount ? '7' : '6'?>" style="padding: 6px 4pt; border-top: 1px solid black; text-align: left;">
|
||||
<?=htmlspecialchars($groupName)?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
<?php
|
||||
foreach($positions as $p):
|
||||
$amount = (float) number_format($p->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, ",",".");
|
||||
|
||||
?>
|
||||
|
||||
<tr class="position <?=($i%2 == 0) ? "even" : "uneven" ?>">
|
||||
<td>
|
||||
<td style="padding-left: 4pt; vertical-align: top;">
|
||||
<?=htmlspecialchars($p->product_name ?? '')?>
|
||||
<?php if(isset($p->product_info) && $p->product_info): ?>
|
||||
<div style="padding-left: 12pt; font-size: 10px; color: #666;"><?=htmlspecialchars($p->product_info)?></div>
|
||||
<?php endif; ?>
|
||||
<?php if(isset($p->matchcode) && $p->matchcode): ?>
|
||||
<div style="padding-left: 12pt"><?=htmlspecialchars($p->matchcode)?></div>
|
||||
<div style="padding-left: 12pt; font-size: 10px; color: #666;"><?=htmlspecialchars($p->matchcode)?></div>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td style="text-align: center;">
|
||||
<?php if($start_date && $end_date): ?>
|
||||
<?php if($timerange_month_only): ?>
|
||||
<?=$start_date->format("m.Y")?>
|
||||
<?php elseif(isset($p->billing_period) && $p->billing_period > 1): ?>
|
||||
<?=$start_date->format("m.Y")?> - <?=$end_date->format("m.Y") ?>
|
||||
<?php else: ?>
|
||||
<?php if($start_date->format("d.m.Y") == $end_date->format("d.m.Y")): ?>
|
||||
<?=$start_date->format("d.m.Y")?>
|
||||
<?php else: ?>
|
||||
<?=$start_date->format("d.m.Y")?> - <?=$end_date->format("d.m.Y") ?>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
<?php elseif($start_date): ?>
|
||||
<?=$start_date->format("d.m.Y")?>
|
||||
<?php else: ?>
|
||||
-
|
||||
<?php endif; ?>
|
||||
|
||||
</td>
|
||||
<td><?=$price?> €</td>
|
||||
<td style="text-align: center"><?=$amount?></td>
|
||||
<td><?=$price_total?> €</td>
|
||||
<td style="text-align: right;"><?=$vatrate?>%</td>
|
||||
<td style="padding-right: 4pt;"><?=$price_gross?> €</td>
|
||||
<td style="text-align: right; padding: 4px 0;"><?=$price?> €</td>
|
||||
<td style="text-align: center; padding: 4px 0;"><?=$amount?> <?=$unit?></td>
|
||||
<?php if($hasDiscount): ?><td style="text-align: right; padding: 4px 0;"><?=number_format($discount, 2, ",", ".")?>%</td><?php endif; ?>
|
||||
<td style="text-align: right; padding: 4px 0;"><?=$price_total?> €</td>
|
||||
<td style="text-align: right; padding: 4px 0;"><?=$vatrate?>%</td>
|
||||
<td style="text-align: right; padding: 4px 0; padding-right: 4pt;"><?=$price_gross?> €</td>
|
||||
</tr>
|
||||
<?php
|
||||
$i++;
|
||||
endforeach;
|
||||
endforeach;
|
||||
?>
|
||||
<tr style="font-weight: bold; background-color: #ebebeb; border-bottom: 1px solid black;border-top: 1px solid black">
|
||||
<td colspan="5">Gesamt Netto:</td>
|
||||
<?php if($gesamtrabatt > 0): ?>
|
||||
<tr style="background-color: #ebebeb; border-top: 2px solid black;">
|
||||
<td colspan="<?=$hasDiscount ? '5' : '4'?>" style="padding: 4px 0;">Zwischensumme:</td>
|
||||
<td colspan="2" style="text-align: right; padding-right: 4pt;"><?=number_format($subtotal, 2, ",","."). " €"?></td>
|
||||
</tr>
|
||||
<tr style="background-color: #ebebeb; border-bottom: 1px solid #ccc;">
|
||||
<td colspan="<?=$hasDiscount ? '5' : '4'?>" style="padding: 4px 0;">Gesamtrabatt <?=number_format($gesamtrabatt, 2, ",", ".")?>%:</td>
|
||||
<td colspan="2" style="text-align: right; padding-right: 4pt; color: #d32f2f;">-<?=number_format($subtotal * ($gesamtrabatt / 100), 2, ",","."). " €"?></td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
<tr style="font-weight: bold; background-color: #ebebeb; border-bottom: 1px solid black;<?=($gesamtrabatt > 0) ? '' : 'border-top: 2px solid black;'?>">
|
||||
<td colspan="<?=$hasDiscount ? '5' : '4'?>" style="padding: 4px 0;">Gesamt Netto:</td>
|
||||
<td colspan="2" style="text-align: right; padding-right: 4pt;"><?=number_format($net_total, 2, ",","."). " €"?></td>
|
||||
</tr>
|
||||
|
||||
@@ -173,7 +205,7 @@ $this->setReturnValue(['filename' => $invoice->invoice_number . ".pdf"]);
|
||||
|
||||
<?php if($rate > 0): ?>
|
||||
<tr style="font-size: 11px;border-bottom: 1px solid black;">
|
||||
<td colspan="5">USt. <?=number_format($rate, 0, ",", ".")?>%:</td>
|
||||
<td colspan="<?=$hasDiscount ? '5' : '4'?>" style="padding: 4px 0;">USt. <?=number_format($rate, 0, ",", ".")?>%:</td>
|
||||
<td colspan="2" style="text-align: right; padding-right: 4pt;"><?=number_format($vat_total, 2, ",","."). " €"?></td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
@@ -182,7 +214,7 @@ $this->setReturnValue(['filename' => $invoice->invoice_number . ".pdf"]);
|
||||
|
||||
<!-- double underline border on bottom -->
|
||||
<tr style="font-weight: bold; border-bottom: 3px double black; background-color: #ebebeb;">
|
||||
<td colspan="5">Gesamt Brutto:</td>
|
||||
<td colspan="<?=$hasDiscount ? '5' : '4'?>" style="padding: 4px 0;">Gesamt Brutto:</td>
|
||||
<td colspan="2" style="text-align: right; padding-right: 4pt;"><?=number_format($gross_total, 2, ",","."). " €"?></td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -193,14 +225,31 @@ $this->setReturnValue(['filename' => $invoice->invoice_number . ".pdf"]);
|
||||
<p style="font-weight: bold;"><?=$invoice->tax_text?></p>
|
||||
<?php endif; ?>
|
||||
<?php if($is_credit): ?>
|
||||
<p style="color: #FF0000; font-weight: bold">Gutschrift! Bitte nicht überweisen.</p>
|
||||
<p style="color: #FF0000; font-weight: bold; text-align: center;">Gutschrift! Bitte nicht überweisen.</p>
|
||||
<?php elseif($invoice->billing_type == "sepa"): ?>
|
||||
<p style="color: #FF0000; font-weight: bold">BITTE NICHT EINZAHLEN, DER BETRAG WIRD AUTOMATISCH VON IHREM KONTO ABGEBUCHT !</p>
|
||||
<p style="color: #FF0000; font-weight: bold; text-align: center;">BITTE NICHT EINZAHLEN – DER BETRAG WIRD AUTOMATISCH VON IHREM KONTO ABGEBUCHT!</p>
|
||||
<?php else: ?>
|
||||
Bitte <b>überweisen</b> Sie den Rechnungsbetrag bis zum <b><?=(new DateTime("@".$invoice->invoice_date))->modify("+14 days")->format("d.m.Y")?></b> auf folgendes Konto:<br />
|
||||
<b style="padding-left: 4pt;">IBAN: <?=$bank_iban?></b><br />
|
||||
<b style="padding-left: 4pt;">BIC: <?=$bank_bic?></b><br /><br />
|
||||
Bitte geben Sie als Verwendungszweck unbedingt die Rechnungsnummer an, nur so können wir Ihre Zahlung eindeutig zuordnen
|
||||
<div style="border-top: 1px solid #ccc; padding-top: 10pt; margin-top: 10pt;">
|
||||
<p style="margin-bottom: 8pt;">
|
||||
<strong>Zahlungsinformationen:</strong>
|
||||
</p>
|
||||
<p style="margin-bottom: 4pt;">
|
||||
Bitte überweisen Sie den Rechnungsbetrag bis zum <strong><?=(new DateTime("@".$invoice->invoice_date))->modify("+14 days")->format("d.m.Y")?></strong> auf folgendes Konto:
|
||||
</p>
|
||||
<table style="margin-left: 20pt; margin-bottom: 12pt;">
|
||||
<tr><td style="width: 100pt;"><strong>IBAN:</strong></td><td><?=$bank_iban?></td></tr>
|
||||
<tr><td><strong>BIC:</strong></td><td><?=$bank_bic?></td></tr>
|
||||
<tr><td><strong>Bank:</strong></td><td><?=$bank_bank?></td></tr>
|
||||
</table>
|
||||
<div style="background-color: #f5f5f5; padding: 10pt; border-left: 3px solid #005384; margin-top: 12pt;">
|
||||
<p style="margin: 0; margin-bottom: 4pt; font-size: 14px;">
|
||||
<strong>Verwendungszweck: <?=$invoice->invoice_number ?? "VORSCHAU"?></strong>
|
||||
</p>
|
||||
<p style="margin: 0; font-size: 10px; color: #666;">
|
||||
Wichtig: Bitte geben Sie den oben angeführten Verwendungszweck bei der Überweisung an, damit wir Ihre Zahlung eindeutig zuordnen können.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<?php
|
||||
|
||||
use PHPMailer\PHPMailer\PHPMailer;
|
||||
use PHPMailer\PHPMailer\Exception;
|
||||
|
||||
class ManualInvoiceController extends TTCrud
|
||||
{
|
||||
protected string $headerTitle = 'Manuelle Rechnungen';
|
||||
@@ -19,9 +22,9 @@ class ManualInvoiceController extends TTCrud
|
||||
['key' => '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 ?? '') ? "<tr><td>Leistungszeitraum:</td><td>" . htmlspecialchars($invoice->leistungszeitraum) . "</td></tr>" : "",
|
||||
"{{ externeReferenzHtml }}" => ($invoice->externe_referenz ?? '') ? "<tr><td>Externe Referenz:</td><td>" . htmlspecialchars($invoice->externe_referenz) . "</td></tr>" : "",
|
||||
"{{ vatHtml }}" => ($invoice->uid ?? '') ? "<tr><td>Ihre UID:</td><td>" . $invoice->uid . "</td></tr>" : "",
|
||||
"{{ 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 = '<!DOCTYPE html><html lang="de"><head><meta charset="UTF-8"><title>Rechnung</title><style>body { font-family: Arial, sans-serif; color: #333; }</style></head><body style="margin:0;padding:20px;background-color:#f3f4f6;">';
|
||||
$html .= '<div style="background-color:#fff;padding:20px;border-radius:8px;max-width:600px;margin:0 auto;box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);">';
|
||||
|
||||
// Logos
|
||||
$html .= '<div style="text-align:center;margin-bottom:20px;border-bottom: 1px solid #e5e7eb;padding-bottom: 15px;">';
|
||||
if ($logoToolExists) $html .= '<img src="cid:logo_thetool" alt="The Tool" style="height:40px;margin-right:15px;vertical-align:middle;">';
|
||||
if ($logoXinonExists) $html .= '<img src="cid:logo_xinon" alt="Xinon" style="height:40px;vertical-align:middle;">';
|
||||
$html .= '</div>';
|
||||
|
||||
$html .= '<h2 style="color:#00558c;text-align:center;font-size:20px;margin-bottom:20px;">' . htmlspecialchars($subject) . '</h2>';
|
||||
$html .= '<div style="font-size:14px;line-height:1.6;color:#333;">';
|
||||
$html .= nl2br(htmlspecialchars($bodyText));
|
||||
$html .= '</div>';
|
||||
|
||||
$html .= '<br><div style="border-top:1px solid #eee;padding-top:20px;font-size:12px;color:#999;text-align:center;">';
|
||||
$html .= 'XINON GmbH | <a href="https://www.xinon.at" style="color:#00558c;text-decoration:none;">www.xinon.at</a>';
|
||||
$html .= '</div></div></body></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(),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
class ManualInvoiceJournalModel extends TTCrudBaseModel {
|
||||
public int $id;
|
||||
public int $manualinvoiceId;
|
||||
public ?string $text;
|
||||
public ?string $data;
|
||||
public ?string $statusChange;
|
||||
public ?string $fileIds;
|
||||
public int $create;
|
||||
public int $createBy;
|
||||
}
|
||||
@@ -3,23 +3,23 @@
|
||||
class ManualInvoicepositionModel extends TTCrudBaseModel {
|
||||
public int $id;
|
||||
public ?int $manualinvoice_id;
|
||||
public ?string $position_group;
|
||||
public ?int $billing_id;
|
||||
public int $contract_id;
|
||||
public string $start_date;
|
||||
public ?string $end_date;
|
||||
public ?string $matchcode;
|
||||
public int $product_id;
|
||||
public string $product_name;
|
||||
public ?string $product_info;
|
||||
public float $amount;
|
||||
public string $unit;
|
||||
public float $price;
|
||||
public float $discount;
|
||||
public float $price_total;
|
||||
public float $price_gross;
|
||||
public float $vatrate;
|
||||
public ?int $fibu_cost_account;
|
||||
public ?int $fibu_cost_account_legacy;
|
||||
public ?int $fibu_taxcode;
|
||||
public int $billing_period;
|
||||
public ?string $options;
|
||||
public int $create_by;
|
||||
public int $edit_by;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use Phinx\Migration\AbstractMigration;
|
||||
|
||||
final class AddManualinvoiceAdditionalFields extends AbstractMigration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if($this->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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use Phinx\Migration\AbstractMigration;
|
||||
|
||||
final class UpdateManualinvoicepositionStructure extends AbstractMigration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if($this->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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use Phinx\Migration\AbstractMigration;
|
||||
|
||||
final class AddUnitToManualinvoiceposition extends AbstractMigration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if($this->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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use Phinx\Migration\AbstractMigration;
|
||||
|
||||
final class CreateManualInvoiceJournal extends AbstractMigration
|
||||
{
|
||||
public function change(): void
|
||||
{
|
||||
if ($this->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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use Phinx\Migration\AbstractMigration;
|
||||
|
||||
final class UpdateManualInvoiceStatusValues extends AbstractMigration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if ($this->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'");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -4,7 +4,7 @@ Vue.component('manual-invoice', {
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<tt-button text="Neue Rechnung" icon="fas fa-plus" @click="openModal()" additional-class="btn-primary"/>
|
||||
</div>
|
||||
<tt-table-crud ref="table" emit-edit @edit="openModal($event)" @createGutschrift="openGutschriftModal($event)">
|
||||
<tt-table-crud ref="table" emit-edit @edit="openModal($event)" @createGutschrift="openGutschriftModal($event)" @pdfPreview="handlePdfPreview($event)" @sendInvoice="handleSendInvoice($event)">
|
||||
<template v-slot:total="{ row }">{{ formatPrice(row.total) }}</template>
|
||||
<template v-slot:total_gross="{ row }">{{ formatPrice(row.total_gross) }}</template>
|
||||
<template v-slot:invoice_date="{ row }">{{ formatDate(row.invoice_date) }}</template>
|
||||
@@ -18,9 +18,10 @@ Vue.component('manual-invoice', {
|
||||
</tt-table-crud>
|
||||
<manual-invoice-modal v-if="isModalOpen" :initial-data="editingInvoiceData" @close="closeModal" @save="handleSave"/>
|
||||
<gutschrift-modal v-if="isGutschriftModalOpen" :invoice-id="gutschriftInvoiceId" @close="closeGutschriftModal" @created="handleGutschriftCreated"/>
|
||||
<send-invoice-modal v-if="isSendModalOpen" :invoice-id="sendInvoiceId" @close="closeSendModal" @sent="handleInvoiceSent"/>
|
||||
</tt-card>
|
||||
`,
|
||||
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', {
|
||||
</tt-card>
|
||||
<tt-card><template v-slot:header><h5><i class="fas fa-file-invoice mr-2"></i>Rechnungsdetails</h5></template>
|
||||
<div class="form-grid">
|
||||
<tt-input label="Rechnungsnr." v-model="invoiceData.invoice_number" sm/>
|
||||
<tt-date-picker label="Rechnungsdatum" v-model="invoiceData.invoice_date" :date-range="false" sm/>
|
||||
<tt-input label="Rechnungsdatum" type="date" v-model="invoiceData.invoice_date" sm/>
|
||||
<tt-select label="Zahlungsart" v-model="invoiceData.billing_type" :options="billingTypeOptions" sm/>
|
||||
</div>
|
||||
<tt-input label="Leistungszeitraum" v-model="invoiceData.leistungszeitraum" sm row placeholder="z.B. 01.01.2025 - 31.01.2025"/>
|
||||
<tt-input label="Externe Referenz" v-model="invoiceData.externe_referenz" sm row placeholder="z.B. Auftragsnummer, Bestellnummer"/>
|
||||
<tt-textarea label="Einleitender Text" v-model="invoiceData.einleitender_text" rows="3" sm row/>
|
||||
</tt-card>
|
||||
<tt-card><template v-slot:header><h5><i class="fas fa-list-ol mr-2"></i>Positionen</h5></template>
|
||||
<tt-positions-manager ref="positionsManager" v-model="invoiceData.positions" :config="positionsConfig" />
|
||||
<tt-positions-manager group-mode ref="positionsManager" v-model="invoiceData.positions" :config="positionsConfig" />
|
||||
</tt-card>
|
||||
<tt-card><template v-slot:header><h5><i class="fas fa-paragraph mr-2"></i>Texte</h5></template>
|
||||
<tt-card><template v-slot:header><h5><i class="fas fa-paragraph mr-2"></i>Texte & Rabatt</h5></template>
|
||||
<tt-input label="Gesamtrabatt (%)" v-model.number="invoiceData.gesamtrabatt" sm row type="number" placeholder="0"/>
|
||||
<tt-textarea label="Steuerhinweis" v-model="invoiceData.tax_text" rows="2"/>
|
||||
</tt-card>
|
||||
</div>
|
||||
@@ -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: `
|
||||
<tt-modal :show="true" @close="close" @submit="handleAction" :submit-text="actionButtonText" :is-loading="loading" size="md">
|
||||
<template v-slot:header>
|
||||
<h5><i class="fas fa-paper-plane mr-2"></i>Rechnung aussenden</h5>
|
||||
</template>
|
||||
<div v-if="!invoice" class="text-center py-4">
|
||||
<tt-loader />
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="mb-3">
|
||||
<strong>Rechnung:</strong> {{ invoice.invoice_number }}<br/>
|
||||
<strong>Kunde:</strong> {{ invoice.customerName }}
|
||||
</div>
|
||||
<hr/>
|
||||
<div class="form-group">
|
||||
<label>Aktion auswählen:</label>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="radio" id="action-email" value="email" v-model="selectedAction" :disabled="!invoice.email">
|
||||
<label class="form-check-label" for="action-email">
|
||||
<i class="fas fa-envelope mr-2"></i>Per E-Mail versenden
|
||||
<span v-if="invoice.email" class="text-muted d-block ml-4">an {{ invoice.email }}</span>
|
||||
<span v-else class="text-danger d-block ml-4">Keine E-Mail-Adresse vorhanden</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check mt-2">
|
||||
<input class="form-check-input" type="radio" id="action-download" value="download" v-model="selectedAction">
|
||||
<label class="form-check-label" for="action-download">
|
||||
<i class="fas fa-download mr-2"></i>PDF herunterladen
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedAction === 'email' && invoice.email" class="mt-3">
|
||||
<tt-input label="E-Mail-Adresse" v-model="emailAddress" sm/>
|
||||
</div>
|
||||
</div>
|
||||
</tt-modal>
|
||||
`,
|
||||
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');
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user