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_4 }}</div>
|
||||||
<div>{{ addressLine_5 }}</div>
|
<div>{{ addressLine_5 }}</div>
|
||||||
</td>
|
</td>
|
||||||
<td style="float: right">
|
<td style="vertical-align: top; text-align: right;">
|
||||||
<table class="info-table">
|
<table style="display: inline-table; vertical-align: top;">
|
||||||
<tr>
|
<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>
|
<td>
|
||||||
<table class="invoice-details">
|
<table class="invoice-details">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -87,16 +89,14 @@
|
|||||||
<td>Belegdatum:</td>
|
<td>Belegdatum:</td>
|
||||||
<td>{{ invoiceDate }}</td>
|
<td>{{ invoiceDate }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
{{ leistungszeitraumHtml }}
|
||||||
|
{{ externeReferenzHtml }}
|
||||||
{{ vatHtml }}
|
{{ vatHtml }}
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,43 @@ $net_total = $invoice->total;
|
|||||||
$gross_total = $invoice->total_gross;
|
$gross_total = $invoice->total_gross;
|
||||||
$is_credit = $net_total < 0;
|
$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"]);
|
$this->setReturnValue(['filename' => $invoice->invoice_number . ".pdf"]);
|
||||||
|
|
||||||
?>
|
?>
|
||||||
@@ -48,12 +85,18 @@ $this->setReturnValue(['filename' => $invoice->invoice_number . ".pdf"]);
|
|||||||
height: 28px;
|
height: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#invoiceTable tr *:nth-child(5),
|
#invoiceTable tr *:nth-child(2),
|
||||||
#invoiceTable tr *:nth-child(4),
|
#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;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#invoiceTable tr *:nth-child(3) {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
#invoiceTable tr *:not(:first-child) {
|
#invoiceTable tr *:not(:first-child) {
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
}
|
}
|
||||||
@@ -72,7 +115,7 @@ $this->setReturnValue(['filename' => $invoice->invoice_number . ".pdf"]);
|
|||||||
}
|
}
|
||||||
|
|
||||||
#invoiceTable tr td:first-child {
|
#invoiceTable tr td:first-child {
|
||||||
max-width: 200pt;
|
max-width: 280pt;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</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>
|
<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">
|
<table style="border-collapse: collapse; width: 100%;" id="invoiceTable">
|
||||||
<tr style="font-weight: bold; border-bottom: 1px solid black;" class="uneven">
|
<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">Leistung / Produkt</th>
|
||||||
<th style="text-align: center">Zeitraum</th>
|
|
||||||
<th style="text-align: right">Preis</th>
|
<th style="text-align: right">Preis</th>
|
||||||
<th style="text-align: center">Menge</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">Netto €</th>
|
||||||
<th style="text-align: right">Ust. %</th>
|
<th style="text-align: right">Ust. %</th>
|
||||||
<th style="text-align: right; padding-right: 4pt">Brutto €</th>
|
<th style="text-align: right; padding-right: 4pt">Brutto €</th>
|
||||||
</tr>
|
</tr>
|
||||||
<?php
|
<?php
|
||||||
$i = 0;
|
$i = 0;
|
||||||
foreach($invoice->positions as $p):
|
foreach($groupedPositions as $groupName => $positions):
|
||||||
$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);
|
?>
|
||||||
|
<!-- Group Header (only show if not default) -->
|
||||||
// Handle dates safely
|
<?php if ($groupName !== '_default'): ?>
|
||||||
$start_date = null;
|
<tr style="background-color: #d9d9d9; font-weight: bold;">
|
||||||
$end_date = null;
|
<td colspan="<?=$hasDiscount ? '7' : '6'?>" style="padding: 6px 4pt; border-top: 1px solid black; text-align: left;">
|
||||||
if (!empty($p->start_date)) {
|
<?=htmlspecialchars($groupName)?>
|
||||||
try {
|
</td>
|
||||||
$start_date = new DateTime($p->start_date);
|
</tr>
|
||||||
} catch (Exception $e) {
|
<?php endif; ?>
|
||||||
$start_date = null;
|
<?php
|
||||||
}
|
foreach($positions as $p):
|
||||||
}
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
$amount = (float) number_format($p->amount ?? 0, 3, ",", ".");
|
$amount = (float) number_format($p->amount ?? 0, 3, ",", ".");
|
||||||
|
$unit = htmlspecialchars($p->unit ?? 'Stk.');
|
||||||
$price = number_format($p->price ?? 0, 2, ",",".");
|
$price = number_format($p->price ?? 0, 2, ",",".");
|
||||||
|
$discount = $p->discount ?? 0;
|
||||||
$price_total = number_format($p->price_total ?? 0, 2, ",",".");
|
$price_total = number_format($p->price_total ?? 0, 2, ",",".");
|
||||||
$price_gross = number_format($p->price_gross ?? 0, 2, ",",".");
|
$price_gross = number_format($p->price_gross ?? 0, 2, ",",".");
|
||||||
$vatrate = number_format($p->vatrate ?? 0, 0, ",",".");
|
$vatrate = number_format($p->vatrate ?? 0, 0, ",",".");
|
||||||
|
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<tr class="position <?=($i%2 == 0) ? "even" : "uneven" ?>">
|
<tr class="position <?=($i%2 == 0) ? "even" : "uneven" ?>">
|
||||||
<td>
|
<td style="padding-left: 4pt; vertical-align: top;">
|
||||||
<?=htmlspecialchars($p->product_name ?? '')?>
|
<?=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): ?>
|
<?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; ?>
|
<?php endif; ?>
|
||||||
</td>
|
</td>
|
||||||
<td style="text-align: center;">
|
<td style="text-align: right; padding: 4px 0;"><?=$price?> €</td>
|
||||||
<?php if($start_date && $end_date): ?>
|
<td style="text-align: center; padding: 4px 0;"><?=$amount?> <?=$unit?></td>
|
||||||
<?php if($timerange_month_only): ?>
|
<?php if($hasDiscount): ?><td style="text-align: right; padding: 4px 0;"><?=number_format($discount, 2, ",", ".")?>%</td><?php endif; ?>
|
||||||
<?=$start_date->format("m.Y")?>
|
<td style="text-align: right; padding: 4px 0;"><?=$price_total?> €</td>
|
||||||
<?php elseif(isset($p->billing_period) && $p->billing_period > 1): ?>
|
<td style="text-align: right; padding: 4px 0;"><?=$vatrate?>%</td>
|
||||||
<?=$start_date->format("m.Y")?> - <?=$end_date->format("m.Y") ?>
|
<td style="text-align: right; padding: 4px 0; padding-right: 4pt;"><?=$price_gross?> €</td>
|
||||||
<?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>
|
|
||||||
</tr>
|
</tr>
|
||||||
<?php
|
<?php
|
||||||
$i++;
|
$i++;
|
||||||
endforeach;
|
endforeach;
|
||||||
|
endforeach;
|
||||||
?>
|
?>
|
||||||
<tr style="font-weight: bold; background-color: #ebebeb; border-bottom: 1px solid black;border-top: 1px solid black">
|
<?php if($gesamtrabatt > 0): ?>
|
||||||
<td colspan="5">Gesamt Netto:</td>
|
<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>
|
<td colspan="2" style="text-align: right; padding-right: 4pt;"><?=number_format($net_total, 2, ",","."). " €"?></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
@@ -173,7 +205,7 @@ $this->setReturnValue(['filename' => $invoice->invoice_number . ".pdf"]);
|
|||||||
|
|
||||||
<?php if($rate > 0): ?>
|
<?php if($rate > 0): ?>
|
||||||
<tr style="font-size: 11px;border-bottom: 1px solid black;">
|
<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>
|
<td colspan="2" style="text-align: right; padding-right: 4pt;"><?=number_format($vat_total, 2, ",","."). " €"?></td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
@@ -182,7 +214,7 @@ $this->setReturnValue(['filename' => $invoice->invoice_number . ".pdf"]);
|
|||||||
|
|
||||||
<!-- double underline border on bottom -->
|
<!-- double underline border on bottom -->
|
||||||
<tr style="font-weight: bold; border-bottom: 3px double black; background-color: #ebebeb;">
|
<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>
|
<td colspan="2" style="text-align: right; padding-right: 4pt;"><?=number_format($gross_total, 2, ",","."). " €"?></td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -193,14 +225,31 @@ $this->setReturnValue(['filename' => $invoice->invoice_number . ".pdf"]);
|
|||||||
<p style="font-weight: bold;"><?=$invoice->tax_text?></p>
|
<p style="font-weight: bold;"><?=$invoice->tax_text?></p>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php if($is_credit): ?>
|
<?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"): ?>
|
<?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: ?>
|
<?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 />
|
<div style="border-top: 1px solid #ccc; padding-top: 10pt; margin-top: 10pt;">
|
||||||
<b style="padding-left: 4pt;">IBAN: <?=$bank_iban?></b><br />
|
<p style="margin-bottom: 8pt;">
|
||||||
<b style="padding-left: 4pt;">BIC: <?=$bank_bic?></b><br /><br />
|
<strong>Zahlungsinformationen:</strong>
|
||||||
Bitte geben Sie als Verwendungszweck unbedingt die Rechnungsnummer an, nur so können wir Ihre Zahlung eindeutig zuordnen
|
</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; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use PHPMailer\PHPMailer\PHPMailer;
|
||||||
|
use PHPMailer\PHPMailer\Exception;
|
||||||
|
|
||||||
class ManualInvoiceController extends TTCrud
|
class ManualInvoiceController extends TTCrud
|
||||||
{
|
{
|
||||||
protected string $headerTitle = 'Manuelle Rechnungen';
|
protected string $headerTitle = 'Manuelle Rechnungen';
|
||||||
@@ -19,9 +22,9 @@ class ManualInvoiceController extends TTCrud
|
|||||||
['key' => 'total', 'text' => 'Netto', 'table' => ['sortable' => true, 'formatter' => 'formatPrice']],
|
['key' => 'total', 'text' => 'Netto', 'table' => ['sortable' => true, 'formatter' => 'formatPrice']],
|
||||||
['key' => 'total_gross', 'text' => 'Brutto', 'table' => ['sortable' => true, 'formatter' => 'formatPrice']],
|
['key' => 'total_gross', 'text' => 'Brutto', 'table' => ['sortable' => true, 'formatter' => 'formatPrice']],
|
||||||
['key' => 'status', 'text' => 'Status', 'table' => ['filter' => 'select', 'filterOptions' => [
|
['key' => 'status', 'text' => 'Status', 'table' => ['filter' => 'select', 'filterOptions' => [
|
||||||
['value' => 'draft', 'text' => 'Entwurf'],
|
['value' => 'erstellt', 'text' => 'Erstellt'],
|
||||||
['value' => 'finalized', 'text' => 'Finalisiert'],
|
['value' => 'gesendet', 'text' => 'Gesendet'],
|
||||||
['value' => 'exported', 'text' => 'Exportiert'],
|
['value' => 'exportiert', 'text' => 'Exportiert'],
|
||||||
]]],
|
]]],
|
||||||
['key' => 'billing_type', 'text' => 'Zahlungsart', 'table' => ['filter' => 'select', 'filterOptions' => [
|
['key' => 'billing_type', 'text' => 'Zahlungsart', 'table' => ['filter' => 'select', 'filterOptions' => [
|
||||||
['value' => 'invoice', 'text' => 'Rechnung'],
|
['value' => 'invoice', 'text' => 'Rechnung'],
|
||||||
@@ -31,7 +34,9 @@ class ManualInvoiceController extends TTCrud
|
|||||||
];
|
];
|
||||||
|
|
||||||
protected array $additionalActions = [
|
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) {
|
protected function createPDFAction($returnFilename = false) {
|
||||||
@@ -46,9 +51,23 @@ class ManualInvoiceController extends TTCrud
|
|||||||
'company' => '', 'firstname' => '', 'lastname' => '',
|
'company' => '', 'firstname' => '', 'lastname' => '',
|
||||||
'street' => '', 'zip' => '', 'city' => '', 'country' => 'Österreich',
|
'street' => '', 'zip' => '', 'city' => '', 'country' => 'Österreich',
|
||||||
'email' => '', 'uid' => '', 'tax_text' => '', 'billing_type' => 'invoice',
|
'email' => '', 'uid' => '', 'tax_text' => '', 'billing_type' => 'invoice',
|
||||||
|
'leistungszeitraum' => '', 'einleitender_text' => '', 'externe_referenz' => '', 'gesamtrabatt' => 0,
|
||||||
'total' => 0, 'total_gross' => 0
|
'total' => 0, 'total_gross' => 0
|
||||||
], $post);
|
], $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 {
|
} else {
|
||||||
$id = $this->request->id ?? $post['id'] ?? null;
|
$id = $this->request->id ?? $post['id'] ?? null;
|
||||||
if (!$id || !($invoice = ManualInvoiceModel::get($id))) {
|
if (!$id || !($invoice = ManualInvoiceModel::get($id))) {
|
||||||
@@ -86,6 +105,8 @@ class ManualInvoiceController extends TTCrud
|
|||||||
"{{ billingAccount }}" => $invoice->fibu_account_number ?? '',
|
"{{ billingAccount }}" => $invoice->fibu_account_number ?? '',
|
||||||
"{{ invoiceNumber }}" => $invoice->invoice_number ?? "VORSCHAU",
|
"{{ invoiceNumber }}" => $invoice->invoice_number ?? "VORSCHAU",
|
||||||
"{{ invoiceDate }}" => date("d.m.Y", $invoice->invoice_date ?? time()),
|
"{{ 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>" : "",
|
"{{ 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))
|
"{{ qrCodeSrc }}" => $this->generateSepaQRCode($invoice->invoice_number ?? "VORSCHAU", round($invoice->total_gross ?? 0, 2))
|
||||||
];
|
];
|
||||||
@@ -134,6 +155,186 @@ class ManualInvoiceController extends TTCrud
|
|||||||
exit;
|
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 {
|
protected function beforeCreate(&$data): bool {
|
||||||
if (isset($data['positions']) && is_array($data['positions'])) {
|
if (isset($data['positions']) && is_array($data['positions'])) {
|
||||||
$this->tempPositions = $data['positions'];
|
$this->tempPositions = $data['positions'];
|
||||||
@@ -143,12 +344,18 @@ class ManualInvoiceController extends TTCrud
|
|||||||
$me = new User();
|
$me = new User();
|
||||||
$me->loadMe();
|
$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([
|
$data = array_merge([
|
||||||
'invoice_number' => ManualInvoiceModel::getNextInvoiceNumber(),
|
'invoice_number' => ManualInvoiceModel::getNextInvoiceNumber(),
|
||||||
'invoice_date' => time(),
|
'invoice_date' => $data['invoice_date'] ?? time(),
|
||||||
'status' => 'draft',
|
'status' => 'erstellt',
|
||||||
'fibu_payment_skonto' => 0,
|
'fibu_payment_skonto' => 0,
|
||||||
'fibu_payment_skonto_rate' => 0,
|
'fibu_payment_skonto_rate' => 0,
|
||||||
|
'gesamtrabatt' => 0,
|
||||||
'total' => 0,
|
'total' => 0,
|
||||||
'total_gross' => 0,
|
'total_gross' => 0,
|
||||||
'create_by' => $me->id,
|
'create_by' => $me->id,
|
||||||
@@ -163,6 +370,17 @@ class ManualInvoiceController extends TTCrud
|
|||||||
protected function afterCreate($data) {
|
protected function afterCreate($data) {
|
||||||
$this->savePositions($data['id']);
|
$this->savePositions($data['id']);
|
||||||
$this->recalculateTotals($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 {
|
protected function beforeUpdate(&$data): bool {
|
||||||
@@ -171,11 +389,16 @@ class ManualInvoiceController extends TTCrud
|
|||||||
unset($data['positions']);
|
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';
|
$this->infoMessages['update'] = 'Rechnung wurde bereits exportiert und kann nicht mehr bearbeitet werden';
|
||||||
return false;
|
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 = new User();
|
||||||
$me->loadMe();
|
$me->loadMe();
|
||||||
$data['edit_by'] = $me->id;
|
$data['edit_by'] = $me->id;
|
||||||
@@ -190,6 +413,16 @@ class ManualInvoiceController extends TTCrud
|
|||||||
|
|
||||||
$this->savePositions($data['id']);
|
$this->savePositions($data['id']);
|
||||||
$this->recalculateTotals($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) {
|
private function savePositions($invoiceId) {
|
||||||
@@ -199,9 +432,18 @@ class ManualInvoiceController extends TTCrud
|
|||||||
$me->loadMe();
|
$me->loadMe();
|
||||||
|
|
||||||
foreach ($this->tempPositions as $position) {
|
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([
|
ManualInvoicepositionModel::create(array_merge([
|
||||||
'manualinvoice_id' => $invoiceId,
|
'manualinvoice_id' => $invoiceId,
|
||||||
'start_date' => date('Y-m-d'),
|
'position_group' => $groupName,
|
||||||
|
'unit' => 'Stk.',
|
||||||
|
'discount' => 0,
|
||||||
'create_by' => $me->id,
|
'create_by' => $me->id,
|
||||||
'edit_by' => $me->id,
|
'edit_by' => $me->id,
|
||||||
'create' => time(),
|
'create' => time(),
|
||||||
@@ -215,8 +457,23 @@ class ManualInvoiceController extends TTCrud
|
|||||||
if (!($invoice = ManualInvoiceModel::get($invoiceId))) return;
|
if (!($invoice = ManualInvoiceModel::get($invoiceId))) return;
|
||||||
|
|
||||||
$positions = ManualInvoicepositionModel::search(['manualinvoice_id' => $invoiceId]);
|
$positions = ManualInvoicepositionModel::search(['manualinvoice_id' => $invoiceId]);
|
||||||
$invoice->total = array_sum(array_column($positions, 'price_total'));
|
$subtotal = array_sum(array_column($positions, 'price_total'));
|
||||||
$invoice->total_gross = array_sum(array_column($positions, 'price_gross'));
|
|
||||||
|
// 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();
|
$invoice->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,23 +529,23 @@ class ManualInvoiceController extends TTCrud
|
|||||||
return [
|
return [
|
||||||
'id' => $pos->id,
|
'id' => $pos->id,
|
||||||
'manualinvoice_id' => $pos->manualinvoice_id,
|
'manualinvoice_id' => $pos->manualinvoice_id,
|
||||||
|
'_group' => $pos->position_group ?? '',
|
||||||
'billing_id' => $pos->billing_id,
|
'billing_id' => $pos->billing_id,
|
||||||
'contract_id' => $pos->contract_id,
|
'contract_id' => $pos->contract_id,
|
||||||
'start_date' => $pos->start_date,
|
|
||||||
'end_date' => $pos->end_date,
|
|
||||||
'matchcode' => $pos->matchcode,
|
'matchcode' => $pos->matchcode,
|
||||||
'product_id' => $pos->product_id,
|
'product_id' => $pos->product_id,
|
||||||
'product_name' => $pos->product_name,
|
'product_name' => $pos->product_name,
|
||||||
'product_info' => $pos->product_info,
|
'product_info' => $pos->product_info,
|
||||||
'amount' => $pos->amount,
|
'amount' => $pos->amount,
|
||||||
|
'unit' => $pos->unit ?? 'Stk.',
|
||||||
'price' => $pos->price,
|
'price' => $pos->price,
|
||||||
|
'discount' => $pos->discount ?? 0,
|
||||||
'price_total' => $pos->price_total,
|
'price_total' => $pos->price_total,
|
||||||
'price_gross' => $pos->price_gross,
|
'price_gross' => $pos->price_gross,
|
||||||
'vatrate' => $pos->vatrate,
|
'vatrate' => $pos->vatrate,
|
||||||
'fibu_cost_account' => $pos->fibu_cost_account,
|
'fibu_cost_account' => $pos->fibu_cost_account,
|
||||||
'fibu_cost_account_legacy' => $pos->fibu_cost_account_legacy,
|
'fibu_cost_account_legacy' => $pos->fibu_cost_account_legacy,
|
||||||
'fibu_taxcode' => $pos->fibu_taxcode,
|
'fibu_taxcode' => $pos->fibu_taxcode,
|
||||||
'billing_period' => $pos->billing_period,
|
|
||||||
'options' => $pos->options
|
'options' => $pos->options
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -340,6 +597,7 @@ class ManualInvoiceController extends TTCrud
|
|||||||
'original_amount' => $pos->amount,
|
'original_amount' => $pos->amount,
|
||||||
'credited_amount' => $creditedAmounts[$key] ?? 0,
|
'credited_amount' => $creditedAmounts[$key] ?? 0,
|
||||||
'available_amount' => $availableAmount,
|
'available_amount' => $availableAmount,
|
||||||
|
'unit' => $pos->unit ?? 'Stk.',
|
||||||
'price' => $pos->price,
|
'price' => $pos->price,
|
||||||
'vatrate' => $pos->vatrate,
|
'vatrate' => $pos->vatrate,
|
||||||
'product_id' => $pos->product_id,
|
'product_id' => $pos->product_id,
|
||||||
@@ -376,6 +634,10 @@ class ManualInvoiceController extends TTCrud
|
|||||||
$invoiceData = [
|
$invoiceData = [
|
||||||
'invoice_number' => ManualInvoiceModel::getNextInvoiceNumber(),
|
'invoice_number' => ManualInvoiceModel::getNextInvoiceNumber(),
|
||||||
'invoice_date' => time(),
|
'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,
|
'owner_id' => $originalInvoice->owner_id,
|
||||||
'billingaddress_id' => $originalInvoice->billingaddress_id,
|
'billingaddress_id' => $originalInvoice->billingaddress_id,
|
||||||
'customer_number' => $originalInvoice->customer_number,
|
'customer_number' => $originalInvoice->customer_number,
|
||||||
@@ -410,7 +672,7 @@ class ManualInvoiceController extends TTCrud
|
|||||||
'total_gross' => 0,
|
'total_gross' => 0,
|
||||||
'vatgroup_id' => $originalInvoice->vatgroup_id,
|
'vatgroup_id' => $originalInvoice->vatgroup_id,
|
||||||
'credit_for_invoice_id' => $originalInvoiceId,
|
'credit_for_invoice_id' => $originalInvoiceId,
|
||||||
'status' => 'finalized',
|
'status' => 'erstellt',
|
||||||
'create' => time(),
|
'create' => time(),
|
||||||
'edit' => time(),
|
'edit' => time(),
|
||||||
'create_by' => $me->id,
|
'create_by' => $me->id,
|
||||||
@@ -425,11 +687,14 @@ class ManualInvoiceController extends TTCrud
|
|||||||
$priceTotal = (-abs($pos['amount'])) * $pos['price'];
|
$priceTotal = (-abs($pos['amount'])) * $pos['price'];
|
||||||
ManualInvoicepositionModel::create([
|
ManualInvoicepositionModel::create([
|
||||||
'manualinvoice_id' => $creditInvoiceId,
|
'manualinvoice_id' => $creditInvoiceId,
|
||||||
|
'position_group' => null,
|
||||||
'product_id' => $pos['product_id'],
|
'product_id' => $pos['product_id'],
|
||||||
'product_name' => $pos['product_name'],
|
'product_name' => $pos['product_name'],
|
||||||
'product_info' => $pos['product_info'] ?? '',
|
'product_info' => $pos['product_info'] ?? '',
|
||||||
'amount' => -abs($pos['amount']),
|
'amount' => -abs($pos['amount']),
|
||||||
|
'unit' => $pos['unit'] ?? 'Stk.',
|
||||||
'price' => $pos['price'],
|
'price' => $pos['price'],
|
||||||
|
'discount' => 0,
|
||||||
'vatrate' => $pos['vatrate'],
|
'vatrate' => $pos['vatrate'],
|
||||||
'price_total' => $priceTotal,
|
'price_total' => $priceTotal,
|
||||||
'price_gross' => $priceTotal * (1 + $pos['vatrate'] / 100),
|
'price_gross' => $priceTotal * (1 + $pos['vatrate'] / 100),
|
||||||
@@ -437,8 +702,7 @@ class ManualInvoiceController extends TTCrud
|
|||||||
'fibu_cost_account' => $pos['fibu_cost_account'] ?? null,
|
'fibu_cost_account' => $pos['fibu_cost_account'] ?? null,
|
||||||
'fibu_taxcode' => $pos['fibu_taxcode'] ?? null,
|
'fibu_taxcode' => $pos['fibu_taxcode'] ?? null,
|
||||||
'contract_id' => 0,
|
'contract_id' => 0,
|
||||||
'start_date' => date('Y-m-d'),
|
'billing_id' => null,
|
||||||
'billing_period' => 0,
|
|
||||||
'create_by' => $me->id,
|
'create_by' => $me->id,
|
||||||
'edit_by' => $me->id,
|
'edit_by' => $me->id,
|
||||||
'create' => time(),
|
'create' => time(),
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ class ManualInvoiceModel extends TTCrudBaseModel {
|
|||||||
public int $id;
|
public int $id;
|
||||||
public ?string $invoice_number;
|
public ?string $invoice_number;
|
||||||
public int $invoice_date;
|
public int $invoice_date;
|
||||||
|
public ?string $leistungszeitraum;
|
||||||
|
public ?string $einleitender_text;
|
||||||
|
public ?string $externe_referenz;
|
||||||
|
public float $gesamtrabatt;
|
||||||
public int $owner_id;
|
public int $owner_id;
|
||||||
public int $billingaddress_id;
|
public int $billingaddress_id;
|
||||||
public int $customer_number;
|
public int $customer_number;
|
||||||
@@ -51,13 +55,13 @@ class ManualInvoiceModel extends TTCrudBaseModel {
|
|||||||
$last = $invoices[0]->invoice_number ?? null;
|
$last = $invoices[0]->invoice_number ?? null;
|
||||||
$year = date("Y");
|
$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;
|
$num = ($m[1] == $year) ? $m[2] + 1 : 1;
|
||||||
} else {
|
} else {
|
||||||
$num = 1;
|
$num = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
return sprintf("MRN%s-X%06d", $year, $num);
|
return sprintf("RN%s-C%06d", $year, $num);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getProperty($name) {
|
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 {
|
class ManualInvoicepositionModel extends TTCrudBaseModel {
|
||||||
public int $id;
|
public int $id;
|
||||||
public ?int $manualinvoice_id;
|
public ?int $manualinvoice_id;
|
||||||
|
public ?string $position_group;
|
||||||
public ?int $billing_id;
|
public ?int $billing_id;
|
||||||
public int $contract_id;
|
public int $contract_id;
|
||||||
public string $start_date;
|
|
||||||
public ?string $end_date;
|
|
||||||
public ?string $matchcode;
|
public ?string $matchcode;
|
||||||
public int $product_id;
|
public int $product_id;
|
||||||
public string $product_name;
|
public string $product_name;
|
||||||
public ?string $product_info;
|
public ?string $product_info;
|
||||||
public float $amount;
|
public float $amount;
|
||||||
|
public string $unit;
|
||||||
public float $price;
|
public float $price;
|
||||||
|
public float $discount;
|
||||||
public float $price_total;
|
public float $price_total;
|
||||||
public float $price_gross;
|
public float $price_gross;
|
||||||
public float $vatrate;
|
public float $vatrate;
|
||||||
public ?int $fibu_cost_account;
|
public ?int $fibu_cost_account;
|
||||||
public ?int $fibu_cost_account_legacy;
|
public ?int $fibu_cost_account_legacy;
|
||||||
public ?int $fibu_taxcode;
|
public ?int $fibu_taxcode;
|
||||||
public int $billing_period;
|
|
||||||
public ?string $options;
|
public ?string $options;
|
||||||
public int $create_by;
|
public int $create_by;
|
||||||
public int $edit_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 {
|
.preview-footer .page-number {
|
||||||
text-align: right;
|
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">
|
<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"/>
|
<tt-button text="Neue Rechnung" icon="fas fa-plus" @click="openModal()" additional-class="btn-primary"/>
|
||||||
</div>
|
</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="{ row }">{{ formatPrice(row.total) }}</template>
|
||||||
<template v-slot:total_gross="{ row }">{{ formatPrice(row.total_gross) }}</template>
|
<template v-slot:total_gross="{ row }">{{ formatPrice(row.total_gross) }}</template>
|
||||||
<template v-slot:invoice_date="{ row }">{{ formatDate(row.invoice_date) }}</template>
|
<template v-slot:invoice_date="{ row }">{{ formatDate(row.invoice_date) }}</template>
|
||||||
@@ -18,9 +18,10 @@ Vue.component('manual-invoice', {
|
|||||||
</tt-table-crud>
|
</tt-table-crud>
|
||||||
<manual-invoice-modal v-if="isModalOpen" :initial-data="editingInvoiceData" @close="closeModal" @save="handleSave"/>
|
<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"/>
|
<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>
|
</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: {
|
methods: {
|
||||||
openModal(invoice = null) {
|
openModal(invoice = null) {
|
||||||
this.editingInvoiceData = invoice ? JSON.parse(JSON.stringify(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 positions = invoiceData.positions.map(p => {
|
||||||
const amount = parseFloat(p.amount) || 0;
|
const amount = parseFloat(p.amount) || 0;
|
||||||
const price = parseFloat(p.price) || 0;
|
const price = parseFloat(p.price) || 0;
|
||||||
|
const discount = parseFloat(p.discount) || 0;
|
||||||
const vatrate = parseFloat(p.vatrate) || 0;
|
const vatrate = parseFloat(p.vatrate) || 0;
|
||||||
|
const priceAfterDiscount = amount * price * (1 - discount / 100);
|
||||||
return {
|
return {
|
||||||
...p, amount, price, vatrate,
|
...p, amount, price, discount, vatrate,
|
||||||
price_total: amount * price,
|
unit: p.unit || 'Stk.',
|
||||||
price_gross: (amount * price) * (1 + vatrate / 100),
|
price_total: priceAfterDiscount,
|
||||||
|
price_gross: priceAfterDiscount * (1 + vatrate / 100),
|
||||||
product_id: p.product_id || 0,
|
product_id: p.product_id || 0,
|
||||||
contract_id: p.contract_id || 0,
|
contract_id: p.contract_id || 0,
|
||||||
billing_id: p.billing_id || null,
|
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,
|
matchcode: p.matchcode || null,
|
||||||
fibu_cost_account: p.fibu_cost_account || null,
|
fibu_cost_account: p.fibu_cost_account || null,
|
||||||
fibu_cost_account_legacy: p.fibu_cost_account_legacy || null,
|
fibu_cost_account_legacy: p.fibu_cost_account_legacy || null,
|
||||||
@@ -66,7 +67,8 @@ Vue.component('manual-invoice', {
|
|||||||
billing_delivery: 'email',
|
billing_delivery: 'email',
|
||||||
fibu_payment_due: 14,
|
fibu_payment_due: 14,
|
||||||
fibu_account_number: invoiceData.fibu_account_number || 0,
|
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;
|
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; },
|
closeGutschriftModal() { this.isGutschriftModalOpen = false; this.gutschriftInvoiceId = null; },
|
||||||
handleGutschriftCreated() { this.closeGutschriftModal(); this.$refs.table.$refs.table.refreshTable(); },
|
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'; },
|
async handlePdfPreview(invoice) {
|
||||||
getStatusText(s) { return { 'draft': 'Entwurf', 'finalized': 'Finalisiert', 'exported': 'Exportiert' }[s] || s; }
|
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>
|
||||||
<tt-card><template v-slot:header><h5><i class="fas fa-file-invoice mr-2"></i>Rechnungsdetails</h5></template>
|
<tt-card><template v-slot:header><h5><i class="fas fa-file-invoice mr-2"></i>Rechnungsdetails</h5></template>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<tt-input label="Rechnungsnr." v-model="invoiceData.invoice_number" sm/>
|
<tt-input label="Rechnungsdatum" type="date" v-model="invoiceData.invoice_date" sm/>
|
||||||
<tt-date-picker label="Rechnungsdatum" v-model="invoiceData.invoice_date" :date-range="false" sm/>
|
|
||||||
<tt-select label="Zahlungsart" v-model="invoiceData.billing_type" :options="billingTypeOptions" sm/>
|
<tt-select label="Zahlungsart" v-model="invoiceData.billing_type" :options="billingTypeOptions" sm/>
|
||||||
</div>
|
</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>
|
||||||
<tt-card><template v-slot:header><h5><i class="fas fa-list-ol mr-2"></i>Positionen</h5></template>
|
<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>
|
||||||
<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-textarea label="Steuerhinweis" v-model="invoiceData.tax_text" rows="2"/>
|
||||||
</tt-card>
|
</tt-card>
|
||||||
</div>
|
</div>
|
||||||
@@ -150,20 +180,31 @@ Vue.component('manual-invoice-modal', {
|
|||||||
pdfPreviewUrl: '',
|
pdfPreviewUrl: '',
|
||||||
previewDebounceTimer: null,
|
previewDebounceTimer: null,
|
||||||
invoiceData: {
|
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,
|
billingaddress_id: null, owner_id: null, customer_number: 0, fibu_account_number: 0,
|
||||||
company: '', firstname: '', lastname: '', street: '', zip: '', city: '', country: 'Österreich',
|
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'}],
|
billingTypeOptions: [{value: 'invoice', text: 'Rechnung'}, {value: 'sepa', text: 'SEPA'}],
|
||||||
positionsConfig: {
|
positionsConfig: {
|
||||||
fields: {
|
fields: {
|
||||||
product_name: { type: 'input', label: 'Bezeichnung' },
|
product_name: { type: 'input', label: 'Bezeichnung' },
|
||||||
product_info: { type: 'input', label: 'Zusatzinfo' },
|
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' },
|
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' },
|
price: { type: 'input', label: 'Einzelpreis (€)', inputType: 'number' },
|
||||||
|
discount: { type: 'input', label: 'Rabatt (%)', inputType: 'number' },
|
||||||
vatrate: { type: 'input', label: 'USt. (%)', inputType: 'number' },
|
vatrate: { type: 'input', label: 'USt. (%)', inputType: 'number' },
|
||||||
},
|
},
|
||||||
validateForm: (d) => {
|
validateForm: (d) => {
|
||||||
@@ -178,14 +219,33 @@ Vue.component('manual-invoice-modal', {
|
|||||||
computed: {
|
computed: {
|
||||||
overlayClasses() { return { 'preview-active-small': !this.isLargeScreen && this.showPreviewOnSmallScreen, 'editor-active-small': !this.isLargeScreen && !this.showPreviewOnSmallScreen }; },
|
overlayClasses() { return { 'preview-active-small': !this.isLargeScreen && this.showPreviewOnSmallScreen, 'editor-active-small': !this.isLargeScreen && !this.showPreviewOnSmallScreen }; },
|
||||||
totals() {
|
totals() {
|
||||||
let net = 0, vat = {};
|
let subtotal = 0;
|
||||||
(this.invoiceData.positions || []).forEach(p => {
|
(this.invoiceData.positions || []).forEach(p => {
|
||||||
const lineTotal = (parseFloat(p.amount) || 0) * (parseFloat(p.price) || 0);
|
const amount = parseFloat(p.amount) || 0;
|
||||||
const r = parseInt(p.vatrate) || 0;
|
const price = parseFloat(p.price) || 0;
|
||||||
net += lineTotal;
|
const discount = parseFloat(p.discount) || 0;
|
||||||
vat[r] = (vat[r] || 0) + lineTotal * (r / 100);
|
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: {
|
watch: {
|
||||||
@@ -249,12 +309,16 @@ Vue.component('manual-invoice-modal', {
|
|||||||
async updatePdfPreview() {
|
async updatePdfPreview() {
|
||||||
this.pdfLoading = true;
|
this.pdfLoading = true;
|
||||||
try {
|
try {
|
||||||
const positions = this.invoiceData.positions.map(p => {
|
const positions = this.invoiceData.positions
|
||||||
const amount = parseFloat(p.amount) || 0;
|
.filter(p => p.product_name && (parseFloat(p.amount) || 0) > 0) // Filter out empty positions
|
||||||
const price = parseFloat(p.price) || 0;
|
.map(p => {
|
||||||
const vatrate = parseFloat(p.vatrate) || 0;
|
const amount = parseFloat(p.amount) || 0;
|
||||||
return { ...p, amount, price, vatrate, price_total: amount * price, price_gross: (amount * price) * (1 + vatrate / 100) };
|
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 = {
|
const payload = {
|
||||||
preview: true, ...this.invoiceData,
|
preview: true, ...this.invoiceData,
|
||||||
@@ -349,4 +413,127 @@ Vue.component('gutschrift-modal', {
|
|||||||
close() { this.$emit('close'); },
|
close() { this.$emit('close'); },
|
||||||
formatPrice(v) { return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(v || 0); }
|
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