Add initial version of Manual Invoice functionality with PDF generation and management
This commit is contained in:
43
Layout/default/ManualInvoice/PDF_FOOTER.html
Normal file
43
Layout/default/ManualInvoice/PDF_FOOTER.html
Normal file
@@ -0,0 +1,43 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Xinon Rechnung</title>
|
||||
<meta charset="utf-8" />
|
||||
</head>
|
||||
<body style="border:0; margin: 0;font-family: sans-serif, Verdana;font-size: 11px;" onload="subst()">
|
||||
|
||||
<script>
|
||||
function subst() {
|
||||
var vars = {};
|
||||
var query_strings_from_url = document.location.search.substring(1).split('&');
|
||||
for (var query_string in query_strings_from_url) {
|
||||
if (query_strings_from_url.hasOwnProperty(query_string)) {
|
||||
var temp_var = query_strings_from_url[query_string].split('=', 2);
|
||||
vars[temp_var[0]] = decodeURI(temp_var[1]);
|
||||
}
|
||||
}
|
||||
var css_selector_classes = ['page', 'frompage', 'topage', 'webpage', 'section', 'subsection', 'date', 'isodate', 'time', 'title', 'doctitle', 'sitepage', 'sitepages'];
|
||||
for (var css_class in css_selector_classes) {
|
||||
if (css_selector_classes.hasOwnProperty(css_class)) {
|
||||
var element = document.getElementsByClassName(css_selector_classes[css_class]);
|
||||
for (var j = 0; j < element.length; ++j) {
|
||||
element[j].textContent = vars[css_selector_classes[css_class]];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div style="margin-bottom: 16px;height: 1px"></div>
|
||||
<div style="color:grey;text-align: center;margin-bottom: 0">
|
||||
<span>XINON GmbH | Fladnitz 150 | 8322 Studenzen</span><br>
|
||||
<span>Tel.: +43 3115 40800 | E-Mail: office@xinon.at</span><br>
|
||||
<span>UID: ATU68711968 | FN: 416556h | LG: Feldbach</span><br>
|
||||
<span>IBAN: {{ bank_iban }} | BIC: {{ bank_bic }}</span><br>
|
||||
</div>
|
||||
|
||||
<div style="text-align: right">Seite <span class="page"></span> von <span class="topage"></span></div>
|
||||
|
||||
<div style="margin-top: 16px;height: 1px"></div>
|
||||
</body>
|
||||
</html>
|
||||
107
Layout/default/ManualInvoice/PDF_HEADER.html
Normal file
107
Layout/default/ManualInvoice/PDF_HEADER.html
Normal file
@@ -0,0 +1,107 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>XINON Invoice Header</title>
|
||||
<meta charset="utf-8" />
|
||||
<style>
|
||||
body {
|
||||
border: 0;
|
||||
margin: 0;
|
||||
font-family: sans-serif, Verdana;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.info-table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.customer-details {
|
||||
vertical-align: bottom;
|
||||
font-size: 14px;
|
||||
padding-left: 30pt;
|
||||
width: 35%;
|
||||
}
|
||||
|
||||
.invoice-details {
|
||||
border: 2px solid #e1e1e1;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.invoice-details td {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.invoice-details td:first-child {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.separator {
|
||||
margin-top: 24px;
|
||||
height: 1px;
|
||||
}
|
||||
|
||||
#topSpacer {
|
||||
margin-bottom: 32px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="topSpacer"></div>
|
||||
|
||||
<div style="height: 50px; margin-bottom: 8px">
|
||||
<img alt="Xinon Logo" src="{{ basedir }}/public/assets/images/xinon-full.png" style="text-align:left;height: 85px;">
|
||||
</div>
|
||||
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<tr>
|
||||
<td class="customer-details">
|
||||
<div>{{ addressLine_1 }}</div>
|
||||
<div>{{ addressLine_2 }}</div>
|
||||
<div>{{ addressLine_3 }}</div>
|
||||
<div>{{ addressLine_4 }}</div>
|
||||
<div>{{ addressLine_5 }}</div>
|
||||
</td>
|
||||
<td style="float: right">
|
||||
<table class="info-table">
|
||||
<tr>
|
||||
<td></td>
|
||||
<td>
|
||||
<table class="invoice-details">
|
||||
<tr>
|
||||
<td>Kundennummer:</td>
|
||||
<td>{{ customerNumber }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Verrechnungskonto:</td>
|
||||
<td>{{ billingAccount }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Rechnungsnummer:</td>
|
||||
<td>{{ invoiceNumber }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Belegdatum:</td>
|
||||
<td>{{ invoiceDate }}</td>
|
||||
</tr>
|
||||
{{ 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>
|
||||
|
||||
|
||||
<div class="separator"></div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
208
Layout/default/ManualInvoice/PDF_MAIN.php
Normal file
208
Layout/default/ManualInvoice/PDF_MAIN.php
Normal file
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
/**
|
||||
* @var string $ressourcePathPrefix
|
||||
* @var ManualInvoice $invoice
|
||||
* @var array $vat
|
||||
*/
|
||||
$net_total = $invoice->total;
|
||||
$gross_total = $invoice->total_gross;
|
||||
$is_credit = $net_total < 0;
|
||||
|
||||
$this->setReturnValue(['filename' => $invoice->invoice_number . ".pdf"]);
|
||||
|
||||
?>
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Rechnung</title>
|
||||
<meta charset="utf-8" />
|
||||
|
||||
<style>
|
||||
body {
|
||||
margin-top: 0;
|
||||
/*padding-top: 20pt;*/
|
||||
font-family: "Open Sans", sans-serif, Verdana;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
tr {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.uneven {
|
||||
background-color: #ebebeb;
|
||||
}
|
||||
|
||||
|
||||
table tr td:last-child {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.additionalRow td:first-child {
|
||||
text-align: left;
|
||||
padding-left: 20pt;
|
||||
}
|
||||
|
||||
th {
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
#invoiceTable tr *:nth-child(5),
|
||||
#invoiceTable tr *:nth-child(4),
|
||||
#invoiceTable tr *:nth-child(3) {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
#invoiceTable tr *:not(:first-child) {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
#invoiceTable tr td {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
tr.position td {
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
tr.position td:first-child {
|
||||
vertical-align: middle !important;
|
||||
padding-left: 4pt;
|
||||
}
|
||||
|
||||
#invoiceTable tr td:first-child {
|
||||
max-width: 200pt;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<div>
|
||||
|
||||
<h2 style="text-align: center;color: #005384">Ihre Xinon <?=($is_credit) ? "Gutschrift" : "Rechnung"?> vom <?=date("d.m.Y",$invoice->invoice_date)?></h2>
|
||||
|
||||
<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>
|
||||
<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;
|
||||
}
|
||||
|
||||
$amount = (float) number_format($p->amount ?? 0, 3, ",", ".");
|
||||
$price = number_format($p->price ?? 0, 2, ",",".");
|
||||
$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>
|
||||
<?=htmlspecialchars($p->product_name ?? '')?>
|
||||
<?php if(isset($p->matchcode) && $p->matchcode): ?>
|
||||
<div style="padding-left: 12pt"><?=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>
|
||||
</tr>
|
||||
<?php
|
||||
$i++;
|
||||
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>
|
||||
<td colspan="2" style="text-align: right; padding-right: 4pt;"><?=number_format($net_total, 2, ",","."). " €"?></td>
|
||||
</tr>
|
||||
|
||||
<?php foreach ($vat as $rate => $vat_total): ?>
|
||||
|
||||
<?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="2" style="text-align: right; padding-right: 4pt;"><?=number_format($vat_total, 2, ",","."). " €"?></td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php endforeach; ?>
|
||||
|
||||
<!-- 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="2" style="text-align: right; padding-right: 4pt;"><?=number_format($gross_total, 2, ",","."). " €"?></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
|
||||
<div style="margin-top: 20pt;">
|
||||
<?php if($invoice->tax_text): ?>
|
||||
<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>
|
||||
<?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>
|
||||
<?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
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
233
application/ManualInvoice/ManualInvoice.php
Normal file
233
application/ManualInvoice/ManualInvoice.php
Normal file
@@ -0,0 +1,233 @@
|
||||
<?php
|
||||
|
||||
use chillerlan\QRCode\QRCode;
|
||||
use chillerlan\QRCode\QROptions;
|
||||
use chillerlan\QRCode\Output\QROutputInterface;
|
||||
|
||||
class ManualInvoice extends mfBaseModel {
|
||||
private $positions;
|
||||
private $pdf;
|
||||
|
||||
|
||||
public function createPdf() {
|
||||
if($this->id) {
|
||||
$invoice_number = $this->invoice_number;
|
||||
$invoice_date = $this->invoice_date;
|
||||
} else {
|
||||
$invoice_number = "PROFORMA";
|
||||
$invoice_date = 1;
|
||||
}
|
||||
|
||||
$filename = "";
|
||||
$positions = $this->getProperty("positions");
|
||||
|
||||
$vat = [];
|
||||
foreach ($positions as $p) {
|
||||
if (!array_key_exists($p->vatrate, $vat)) {
|
||||
$vat[$p->vatrate] = 0;
|
||||
}
|
||||
$vat[$p->vatrate] += $p->price_gross - ($p->price * $p->amount);
|
||||
}
|
||||
|
||||
$pdf_vars = [
|
||||
"invoice" => $this,
|
||||
"vat" => $vat,
|
||||
"bank_iban" => TT_INVOICE_BANK_IBAN,
|
||||
"bank_bic" => TT_INVOICE_BANK_BIC,
|
||||
"bank_bank"=> TT_INVOICE_BANK_BANK,
|
||||
"bank_owner" => TT_INVOICE_BANK_OWNER
|
||||
];
|
||||
|
||||
// Replace placeholders in header
|
||||
$headerHtml = file_get_contents(BASEDIR . "/Layout/default/ManualInvoice/PDF_HEADER.html");
|
||||
$headerHtml = str_replace("{{ basedir }}", BASEDIR, $headerHtml);
|
||||
$headerHtml = str_replace("{{ addressLine_1 }}", $this->company ? $this->company : "", $headerHtml);
|
||||
$headerHtml = str_replace("{{ addressLine_2 }}", $this->firstname . " " . $this->lastname, $headerHtml);
|
||||
$headerHtml = str_replace("{{ addressLine_3 }}", nl2br($this->street), $headerHtml);
|
||||
$headerHtml = str_replace("{{ addressLine_4 }}", $this->zip . " " . $this->city, $headerHtml);
|
||||
$headerHtml = str_replace("{{ addressLine_5 }}", $this->country != "Österreich" ? $this->country : "", $headerHtml);
|
||||
$headerHtml = str_replace("{{ customerNumber }}", $this->customer_number, $headerHtml);
|
||||
$headerHtml = str_replace("{{ billingAccount }}", $this->fibu_account_number, $headerHtml);
|
||||
$headerHtml = str_replace("{{ invoiceNumber }}", $invoice_number, $headerHtml);
|
||||
$headerHtml = str_replace("{{ invoiceDate }}", date("d.m.Y", $invoice_date), $headerHtml);
|
||||
$headerHtml = str_replace("{{ vatHtml }}", $this->uid ? "<tr><td>Ihre UID:</td><td>" . $this->uid . "</td></tr>" : "", $headerHtml);
|
||||
$headerHtml = str_replace("{{ qrCodeSrc }}", $this->getSepaQRCode($invoice_number, round($this->total_gross, 2)), $headerHtml);
|
||||
|
||||
$headerFile = BASEDIR . "/var/temp/manualinvoice_header-" . date("U") . "-" . rand(1000, 9999) . ".html";
|
||||
file_put_contents($headerFile, $headerHtml);
|
||||
|
||||
|
||||
// Replace placeholders in footer
|
||||
$footerHtml = file_get_contents(BASEDIR . "/Layout/default/ManualInvoice/PDF_FOOTER.html");
|
||||
$footerHtml = str_replace("{{ bank_iban }}", TT_INVOICE_BANK_IBAN_FORMATTED, $footerHtml);
|
||||
$footerHtml = str_replace("{{ bank_bic }}", TT_INVOICE_BANK_BIC, $footerHtml);
|
||||
$footerHtml = str_replace("{{ bank_bank }}", TT_INVOICE_BANK_BANK, $footerHtml);
|
||||
$footerHtml = str_replace("{{ bank_owner }}", TT_INVOICE_BANK_OWNER, $footerHtml);
|
||||
|
||||
|
||||
$footerFile = BASEDIR . "/var/temp/manualinvoice_footer-" . date("U") . "-" . rand(1000, 9999) . ".html";
|
||||
file_put_contents($footerFile, $footerHtml);
|
||||
|
||||
|
||||
$pdf = new PdfForm("ManualInvoice/PDF_MAIN", $pdf_vars);
|
||||
$wkhtmltopdfArgs = "--header-html $headerFile --footer-html $footerFile";
|
||||
$filename = $pdf->render($wkhtmltopdfArgs);
|
||||
|
||||
return $filename;
|
||||
|
||||
}
|
||||
|
||||
public function getSepaQRCode($paymentReference, $amount) {
|
||||
$xinonIBAN = TT_INVOICE_BANK_IBAN;
|
||||
$xinonBIC = TT_INVOICE_BANK_BIC;
|
||||
$xinonOwner = TT_INVOICE_BANK_OWNER;
|
||||
|
||||
$epc = "BCD
|
||||
001
|
||||
1
|
||||
SCT
|
||||
$xinonBIC
|
||||
$xinonOwner
|
||||
$xinonIBAN
|
||||
EUR$amount
|
||||
XINO
|
||||
$paymentReference
|
||||
|
||||
XINON GmbH";
|
||||
|
||||
return (new QRCode)->render($epc);
|
||||
}
|
||||
|
||||
|
||||
public function sendByEmail($to_email = false) {
|
||||
if(!$this->id) return false;
|
||||
|
||||
$pdf = $this->getProperty("pdf");
|
||||
if(!$pdf || !$pdf->name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$pdf_filename = false;
|
||||
try {
|
||||
$pdf_filename = $pdf->getFullPath();
|
||||
} catch(\Exception $e) {
|
||||
$this->log->error("File for ManualInvoice ".$this->id." not found");
|
||||
}
|
||||
|
||||
if(!$pdf_filename || !file_exists($pdf_filename)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$tpl = new Layout();
|
||||
$tpl->setTemplate("Emailtemplates/invoice/invoice-email");
|
||||
|
||||
$pdf_vars = [
|
||||
"invoice" => $this
|
||||
];
|
||||
|
||||
foreach($pdf_vars as $name => $val) {
|
||||
$tpl->set($name, $val);
|
||||
}
|
||||
|
||||
$body = $tpl->render();
|
||||
$values = $tpl->getReturnedValue();
|
||||
|
||||
$subject = $values['subject'];
|
||||
$from = $values['from_email'];
|
||||
$from_name = $values['from_email_name'];
|
||||
if($to_email) {
|
||||
$to = $to_email;
|
||||
} else {
|
||||
$to = trim($this->email);
|
||||
}
|
||||
|
||||
if(!$to) {
|
||||
$this->log->error(__METHOD__.": ManualInvoice ".$this->invoice_number." missing email");
|
||||
}
|
||||
|
||||
|
||||
|
||||
if(!$subject || !$from || !$from_name || !$to) {
|
||||
$this->log->warn(__METHOD__.": ManualInvoice ".$this->invoice_number." could not be sent. Values missing. (subject: '$subject', from: '$from_name', from_email: '$from', to: '$to')");
|
||||
return false;
|
||||
} else {
|
||||
$email = new Emailnotification("ManualInvoice", $this->id);
|
||||
$email->setSubject($subject);
|
||||
$email->setBody($body);
|
||||
$email->setFrom($from, $from_name);
|
||||
$email->setTo($to);
|
||||
$email->setHeader("X-".MFAPPNAME."-Iid", $this->id);
|
||||
$email->addAttachment($pdf_filename, null, $pdf->filename, "application/pdf");
|
||||
$email->send();
|
||||
$this->log->info(__METHOD__.": Sending ManualInvoice ".$this->invoice_number." to $to");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getProperty($name) {
|
||||
if($this->$name == null) {
|
||||
|
||||
if(!$this->id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
if($name == "positions") {
|
||||
$positions = ManualInvoicepositionModel::search(["manualinvoice_id" => $this->id]);
|
||||
$this->positions = $positions;
|
||||
return $this->positions;
|
||||
}
|
||||
|
||||
if($name == "pdf") {
|
||||
$ifile = ManualInvoiceFileModel::getFirst(["manualinvoice_id" => $this->id]);
|
||||
if(!$ifile) return null;
|
||||
|
||||
$file = $ifile->file;
|
||||
if(!$file) return null;
|
||||
|
||||
$this->pdf = $file;
|
||||
return $this->pdf;
|
||||
}
|
||||
|
||||
if($name == "creator") {
|
||||
$this->creator = mfValuecache::singleton()->get("Worker-id-".$this->create_by);
|
||||
if($this->creator === null) {
|
||||
$this->creator = new User($this->create_by);
|
||||
if($this->creator->id) {
|
||||
mfValuecache::singleton()->set("Worker-id-".$this->create_by, $this->creator);
|
||||
}
|
||||
}
|
||||
return $this->creator;
|
||||
}
|
||||
|
||||
if($name == "editor") {
|
||||
$this->editor = mfValuecache::singleton()->get("Worker-id-".$this->edit_by);
|
||||
if($this->editor === null) {
|
||||
$this->editor = new User($this->edit_by);
|
||||
if($this->editor->id) {
|
||||
mfValuecache::singleton()->set("Worker-id-".$this->edit_by, $this->editor);
|
||||
}
|
||||
}
|
||||
return $this->editor;
|
||||
}
|
||||
|
||||
$classname = ucfirst($name);
|
||||
$idfield = $name."_id";
|
||||
$this->$name = mfValuecache::singleton()->get("mfObjectmodel-$name-".$this->$idfield);
|
||||
if(!$this->$name) {
|
||||
$this->$name = new $classname($this->$idfield);
|
||||
}
|
||||
|
||||
if($this->$name->id) {
|
||||
mfValuecache::singleton()->set("mfObjectmodel-$name-".$this->$name->id, $this->$name);
|
||||
return $this->$name;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return $this->$name;
|
||||
}
|
||||
}
|
||||
@@ -5,18 +5,277 @@ class ManualInvoiceController extends TTCrud
|
||||
protected string $headerTitle = 'Manuelle Rechnungen';
|
||||
protected bool $createText = false;
|
||||
|
||||
protected array $additionalJS = ["js/pages/ManualInvoice/ManualInvoice.js"];
|
||||
protected array $additionalHead = ["<link rel='stylesheet' href='/js/pages/ManualInvoice/ManualInvoice.css'>"];
|
||||
|
||||
//@formatter:off
|
||||
protected array $columns = [
|
||||
['key' => 'invoiceNumber', 'text' => 'Rechnungsnr.', 'table' => ['sortable' => true, 'filter' => 'search']],
|
||||
['key' => 'customerName', 'text' => 'Kunde', 'table' => ['sortable' => true, 'filter' => 'search']],
|
||||
['key' => 'invoiceDate', 'text' => 'Datum', 'table' => ['sortable' => true, 'filter' => 'date']],
|
||||
['key' => 'totalAmount', 'text' => 'Betrag', 'table' => ['sortable' => true, 'formatter' => 'formatPrice']],
|
||||
['key' => 'status', 'text' => 'Status', 'table' => ['filter' => 'select', 'filterOptions' => [
|
||||
['value' => 'draft', 'text' => 'Entwurf'],
|
||||
['value' => 'sent', 'text' => 'Gesendet'],
|
||||
['value' => 'paid', 'text' => 'Bezahlt'],
|
||||
['key' => 'id', 'text' => 'ID', 'table' => ['visible' => false], 'modal' => false],
|
||||
['key' => 'invoice_number', 'text' => 'Rechnungsnr.', 'table' => ['sortable' => true, 'filter' => 'search']],
|
||||
['key' => 'invoice_date', 'text' => 'Datum', 'type' => 'timestamp', 'table' => ['sortable' => true, 'filter' => 'date', 'formatter' => 'formatDate']],
|
||||
['key' => 'company', 'text' => 'Firma', 'table' => ['sortable' => true, 'filter' => 'search']],
|
||||
['key' => 'firstname', 'text' => 'Vorname', 'table' => ['visible' => false], 'modal' => false],
|
||||
['key' => 'lastname', 'text' => 'Nachname', 'table' => ['visible' => false], 'modal' => false],
|
||||
['key' => 'customer_number', 'text' => 'Kundennr.', 'table' => ['sortable' => true, 'filter' => 'search']],
|
||||
['key' => 'total', 'text' => 'Netto', 'table' => ['sortable' => true, 'formatter' => 'formatPrice']],
|
||||
['key' => 'total_gross', 'text' => 'Brutto', 'table' => ['sortable' => true, 'formatter' => 'formatPrice']],
|
||||
['key' => 'billing_type', 'text' => 'Zahlungsart', 'table' => ['filter' => 'select', 'filterOptions' => [
|
||||
['value' => 'invoice', 'text' => 'Rechnung'],
|
||||
['value' => 'sepa', 'text' => 'SEPA'],
|
||||
]]],
|
||||
['key' => 'actions', 'text' => 'Aktionen', 'modal' => false, 'table' => ['filter' => false, 'sortable' => false]],
|
||||
];
|
||||
//@formatter:on
|
||||
}
|
||||
|
||||
protected function createPDFAction($returnFilename = false) {
|
||||
// Get data from POST for preview or from database for saved invoice
|
||||
$post = json_decode(file_get_contents('php://input'), true);
|
||||
|
||||
if (isset($post['preview']) && $post['preview'] === true) {
|
||||
// Create temporary invoice object from POST data for preview
|
||||
$invoice = (object)[];
|
||||
$invoice->id = 0;
|
||||
$invoice->invoice_number = $post['invoice_number'] ?? null;
|
||||
$invoice->invoice_date = $post['invoice_date'] ?? time();
|
||||
$invoice->customer_number = $post['customer_number'] ?? 0;
|
||||
$invoice->fibu_account_number = $post['fibu_account_number'] ?? 0;
|
||||
$invoice->company = $post['company'] ?? '';
|
||||
$invoice->firstname = $post['firstname'] ?? '';
|
||||
$invoice->lastname = $post['lastname'] ?? '';
|
||||
$invoice->street = $post['street'] ?? '';
|
||||
$invoice->zip = $post['zip'] ?? '';
|
||||
$invoice->city = $post['city'] ?? '';
|
||||
$invoice->country = $post['country'] ?? 'Österreich';
|
||||
$invoice->email = $post['email'] ?? '';
|
||||
$invoice->uid = $post['uid'] ?? '';
|
||||
$invoice->tax_text = $post['tax_text'] ?? '';
|
||||
$invoice->billing_type = $post['billing_type'] ?? 'invoice';
|
||||
$invoice->total = $post['total'] ?? 0;
|
||||
$invoice->total_gross = $post['total_gross'] ?? 0;
|
||||
|
||||
$positions = [];
|
||||
foreach ($post['positions'] ?? [] as $pos) {
|
||||
$positions[] = (object)$pos;
|
||||
}
|
||||
} else {
|
||||
// Load from database
|
||||
$id = $this->request->id ?? $post['id'] ?? null;
|
||||
if (!$id) {
|
||||
http_response_code(500);
|
||||
self::returnJson(['success' => false, 'message' => 'Rechnung wurde nicht gefunden']);
|
||||
return;
|
||||
}
|
||||
|
||||
$invoice = new ManualInvoice($id);
|
||||
if (!$invoice->id) {
|
||||
http_response_code(500);
|
||||
self::returnJson(['success' => false, 'message' => 'Rechnung wurde nicht gefunden']);
|
||||
return;
|
||||
}
|
||||
|
||||
$positions = $invoice->getProperty('positions');
|
||||
}
|
||||
|
||||
// Calculate VAT totals
|
||||
$vat = [];
|
||||
foreach ($positions as $p) {
|
||||
$vatrate = is_object($p) ? $p->vatrate : $p['vatrate'];
|
||||
$price_gross = is_object($p) ? $p->price_gross : ($p['price_gross'] ?? 0);
|
||||
$price_total = is_object($p) ? $p->price_total : ($p['price_total'] ?? 0);
|
||||
|
||||
if (!array_key_exists($vatrate, $vat)) {
|
||||
$vat[$vatrate] = 0;
|
||||
}
|
||||
$vat[$vatrate] += $price_gross - $price_total;
|
||||
}
|
||||
|
||||
// Convert positions array to objects if needed
|
||||
$invoice->positions = $positions;
|
||||
|
||||
$pdf_vars = [
|
||||
"invoice" => $invoice,
|
||||
"vat" => $vat,
|
||||
"bank_iban" => TT_INVOICE_BANK_IBAN,
|
||||
"bank_bic" => TT_INVOICE_BANK_BIC,
|
||||
"bank_bank" => TT_INVOICE_BANK_BANK,
|
||||
"bank_owner" => TT_INVOICE_BANK_OWNER
|
||||
];
|
||||
|
||||
// Replace placeholders in header
|
||||
$headerHtml = file_get_contents(BASEDIR . "/Layout/default/ManualInvoice/PDF_HEADER.html");
|
||||
$headerHtml = str_replace("{{ basedir }}", BASEDIR, $headerHtml);
|
||||
$headerHtml = str_replace("{{ addressLine_1 }}", $invoice->company ? $invoice->company : "", $headerHtml);
|
||||
$headerHtml = str_replace("{{ addressLine_2 }}", trim($invoice->firstname . " " . $invoice->lastname), $headerHtml);
|
||||
$headerHtml = str_replace("{{ addressLine_3 }}", $invoice->street ?? '', $headerHtml);
|
||||
$headerHtml = str_replace("{{ addressLine_4 }}", ($invoice->zip ?? '') . " " . ($invoice->city ?? ''), $headerHtml);
|
||||
$headerHtml = str_replace("{{ addressLine_5 }}", ($invoice->country ?? '') != "Österreich" ? ($invoice->country ?? '') : "", $headerHtml);
|
||||
$headerHtml = str_replace("{{ customerNumber }}", $invoice->customer_number ?? '', $headerHtml);
|
||||
$headerHtml = str_replace("{{ billingAccount }}", $invoice->fibu_account_number ?? '', $headerHtml);
|
||||
$headerHtml = str_replace("{{ invoiceNumber }}", $invoice->invoice_number ?? "VORSCHAU", $headerHtml);
|
||||
$headerHtml = str_replace("{{ invoiceDate }}", date("d.m.Y", $invoice->invoice_date ?? time()), $headerHtml);
|
||||
$headerHtml = str_replace("{{ vatHtml }}", ($invoice->uid ?? '') ? "<tr><td>Ihre UID:</td><td>" . $invoice->uid . "</td></tr>" : "", $headerHtml);
|
||||
|
||||
// Generate QR code for SEPA payment
|
||||
$qrCode = $this->generateSepaQRCode($invoice->invoice_number ?? "VORSCHAU", round($invoice->total_gross ?? 0, 2));
|
||||
$headerHtml = str_replace("{{ qrCodeSrc }}", $qrCode, $headerHtml);
|
||||
|
||||
$headerFile = BASEDIR . "/var/temp/manualinvoice_header-" . date("U") . "-" . rand(1000, 9999) . ".html";
|
||||
file_put_contents($headerFile, $headerHtml);
|
||||
|
||||
// Replace placeholders in footer
|
||||
$footerHtml = file_get_contents(BASEDIR . "/Layout/default/ManualInvoice/PDF_FOOTER.html");
|
||||
$footerHtml = str_replace("{{ bank_iban }}", TT_INVOICE_BANK_IBAN_FORMATTED, $footerHtml);
|
||||
$footerHtml = str_replace("{{ bank_bic }}", TT_INVOICE_BANK_BIC, $footerHtml);
|
||||
$footerHtml = str_replace("{{ bank_bank }}", TT_INVOICE_BANK_BANK, $footerHtml);
|
||||
$footerHtml = str_replace("{{ bank_owner }}", TT_INVOICE_BANK_OWNER, $footerHtml);
|
||||
|
||||
$footerFile = BASEDIR . "/var/temp/manualinvoice_footer-" . date("U") . "-" . rand(1000, 9999) . ".html";
|
||||
file_put_contents($footerFile, $footerHtml);
|
||||
|
||||
$pdf = new PdfForm("ManualInvoice/PDF_MAIN", $pdf_vars);
|
||||
$wkhtmltopdfArgs = "--header-html $headerFile --footer-html $footerFile";
|
||||
$filename = $pdf->render($wkhtmltopdfArgs);
|
||||
|
||||
if ($returnFilename === true) return $filename;
|
||||
|
||||
// Return the PDF inline for preview
|
||||
header('Content-Type: application/pdf');
|
||||
header('Content-Disposition: inline; filename="' . ($invoice->invoice_number ?? 'preview') . '.pdf"');
|
||||
readfile($filename);
|
||||
die();
|
||||
}
|
||||
|
||||
protected function downloadInvoicePdfAction() {
|
||||
$id = $this->request->id;
|
||||
if (!is_numeric($id) || !$id) {
|
||||
$this->layout()->setFlash("Rechnung nicht gefunden", "error");
|
||||
$this->redirect("ManualInvoice");
|
||||
}
|
||||
|
||||
$invoice = new ManualInvoice($id);
|
||||
if (!$invoice->id) {
|
||||
$this->layout()->setFlash("Rechnung nicht gefunden", "error");
|
||||
$this->redirect("ManualInvoice");
|
||||
}
|
||||
|
||||
// Use createPDFAction to get filename
|
||||
$pdf_filename = $this->createPDFAction(true);
|
||||
|
||||
if(!$pdf_filename || !file_exists($pdf_filename)) {
|
||||
$this->layout()->setFlash("PDF-Datei konnte nicht erstellt werden");
|
||||
$this->redirect("ManualInvoice");
|
||||
}
|
||||
|
||||
header('Content-Type: application/octet-stream');
|
||||
header('Content-disposition: attachment; filename="'.$invoice->invoice_number.'.pdf"');
|
||||
header('Content-Transfer-Encoding: binary');
|
||||
header('Cache-Control: must-revalidate, post-check=0, pre-check=0');
|
||||
header('Content-Type: application/pdf');
|
||||
header("Content-Length: " . filesize($pdf_filename));
|
||||
|
||||
readfile($pdf_filename);
|
||||
exit;
|
||||
}
|
||||
|
||||
protected function beforeCreate(&$data): bool {
|
||||
// Generate invoice number if not provided
|
||||
if (empty($data['invoice_number'])) {
|
||||
$data['invoice_number'] = ManualInvoiceModel::getNextInvoiceNumber();
|
||||
}
|
||||
|
||||
// Set default values
|
||||
if (empty($data['invoice_date'])) {
|
||||
$data['invoice_date'] = time();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function afterCreate($data) {
|
||||
$invoiceId = $data['id'];
|
||||
|
||||
// Save positions
|
||||
if (isset($data['positions']) && is_array($data['positions'])) {
|
||||
foreach ($data['positions'] as $position) {
|
||||
$position['manualinvoice_id'] = $invoiceId;
|
||||
$posModel = ManualInvoicepositionModel::create($position);
|
||||
$posModel->save();
|
||||
}
|
||||
}
|
||||
|
||||
// Recalculate totals
|
||||
$this->recalculateTotals($invoiceId);
|
||||
}
|
||||
|
||||
protected function beforeUpdate(&$data): bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function afterUpdate($data) {
|
||||
$invoiceId = $data['id'];
|
||||
|
||||
// Delete existing positions
|
||||
$existingPositions = ManualInvoicepositionModel::search(['manualinvoice_id' => $invoiceId]);
|
||||
foreach ($existingPositions as $pos) {
|
||||
$pos->delete();
|
||||
}
|
||||
|
||||
// Save new positions
|
||||
if (isset($data['positions']) && is_array($data['positions'])) {
|
||||
foreach ($data['positions'] as $position) {
|
||||
$position['manualinvoice_id'] = $invoiceId;
|
||||
$posModel = ManualInvoicepositionModel::create($position);
|
||||
$posModel->save();
|
||||
}
|
||||
}
|
||||
|
||||
// Recalculate totals
|
||||
$this->recalculateTotals($invoiceId);
|
||||
}
|
||||
|
||||
protected function recalculateTotals($invoiceId) {
|
||||
$invoice = new ManualInvoice($invoiceId);
|
||||
$positions = ManualInvoicepositionModel::search(['manualinvoice_id' => $invoiceId]);
|
||||
|
||||
$total = 0;
|
||||
$total_gross = 0;
|
||||
|
||||
foreach ($positions as $pos) {
|
||||
$total += $pos->price_total;
|
||||
$total_gross += $pos->price_gross;
|
||||
}
|
||||
|
||||
$invoice->total = $total;
|
||||
$invoice->total_gross = $total_gross;
|
||||
$invoice->save();
|
||||
}
|
||||
|
||||
protected function customRowsHandler($rows) {
|
||||
foreach ($rows as &$row) {
|
||||
// Add customer name
|
||||
$row->customerName = trim(($row->company ? $row->company : '') . ' ' . $row->firstname . ' ' . $row->lastname);
|
||||
}
|
||||
return $rows;
|
||||
}
|
||||
|
||||
protected function generateSepaQRCode($paymentReference, $amount) {
|
||||
$xinonIBAN = TT_INVOICE_BANK_IBAN;
|
||||
$xinonBIC = TT_INVOICE_BANK_BIC;
|
||||
$xinonOwner = TT_INVOICE_BANK_OWNER;
|
||||
|
||||
$epc = "BCD
|
||||
001
|
||||
1
|
||||
SCT
|
||||
$xinonBIC
|
||||
$xinonOwner
|
||||
$xinonIBAN
|
||||
EUR$amount
|
||||
XINO
|
||||
$paymentReference
|
||||
|
||||
XINON GmbH";
|
||||
|
||||
return (new \chillerlan\QRCode\QRCode)->render($epc);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,186 +1,408 @@
|
||||
<?php
|
||||
|
||||
function getMockData() {
|
||||
$mockData = [
|
||||
[
|
||||
'id' => 1, 'invoiceNumber' => 'RE-2025-001', 'customerName' => 'Musterfirma GmbH', 'billingAddressId' => 1,
|
||||
'invoiceDate' => strtotime('2025-09-11'), 'dueDate' => strtotime('2025-09-25'), 'totalAmount' => 948.00, 'status' => 'paid',
|
||||
'positions' => json_encode([
|
||||
['product_name' => 'IT-Support-Stunden', 'product_info' => 'Remote-Hilfe für Mitarbeiter', 'start_date' => '2025-09-10', 'end_date' => '2025-09-10', 'amount' => 2, 'price' => 120.00, 'vatrate' => 20],
|
||||
['product_name' => 'Netzwerk-Switch 24-Port', 'product_info' => 'Modell: XYZ-24G', 'start_date' => '2025-09-10', 'end_date' => '2025-09-10', 'amount' => 1, 'price' => 550.00, 'vatrate' => 20],
|
||||
]),
|
||||
'closingText' => 'Vielen Dank für Ihren Auftrag.', 'taxText' => ''
|
||||
],
|
||||
[
|
||||
'id' => 2, 'invoiceNumber' => 'RE-2025-002', 'customerName' => 'Beispiel AG', 'billingAddressId' => 2,
|
||||
'invoiceDate' => strtotime('2025-09-14'), 'dueDate' => strtotime('2025-09-28'), 'totalAmount' => 720.00, 'status' => 'sent',
|
||||
'positions' => json_encode([
|
||||
['product_name' => 'Beratung Digitalisierungsstrategie', 'product_info' => 'Workshop am 05.09.2025', 'start_date' => '2025-09-05', 'end_date' => '2025-09-05', 'amount' => 4, 'price' => 150.00, 'vatrate' => 20],
|
||||
]),
|
||||
'closingText' => 'Wir freuen uns auf eine weiterhin gute Zusammenarbeit.', 'taxText' => ''
|
||||
],
|
||||
[
|
||||
'id' => 3, 'invoiceNumber' => 'RE-2025-003', 'customerName' => 'John Doe Services', 'billingAddressId' => 3,
|
||||
'invoiceDate' => strtotime('2025-09-16'), 'dueDate' => strtotime('2025-09-30'), 'totalAmount' => 912.00, 'status' => 'draft',
|
||||
'positions' => json_encode([
|
||||
['product_name' => 'Kabelverlegung LWL', 'product_info' => 'Inhouse-Verkabelung Bürogebäude', 'start_date' => '2025-09-15', 'end_date' => '2025-09-15', 'amount' => 8, 'price' => 85.00, 'vatrate' => 20],
|
||||
['product_name' => 'LWL-Kabel 8 Fasern', 'product_info' => 'Pro Meter', 'start_date' => '2025-09-15', 'end_date' => '2025-09-15', 'amount' => 100, 'price' => 0.80, 'vatrate' => 20],
|
||||
]),
|
||||
'closingText' => 'Bei Fragen stehen wir Ihnen gerne zur Verfügung.', 'taxText' => ''
|
||||
],
|
||||
[
|
||||
'id' => 4, 'invoiceNumber' => 'RE-2025-004', 'customerName' => 'Bau & Co KG', 'billingAddressId' => 4,
|
||||
'invoiceDate' => strtotime('2025-09-06'), 'dueDate' => strtotime('2025-09-20'), 'totalAmount' => 1890.00, 'status' => 'paid',
|
||||
'positions' => json_encode([
|
||||
['product_name' => 'Netzwerk-Grundinstallation Baustelle', 'product_info' => 'Containerdorf Einrichtung', 'start_date' => '2025-09-02', 'end_date' => '2025-09-02', 'amount' => 1, 'price' => 1200.00, 'vatrate' => 20],
|
||||
['product_name' => 'Stunden Elektriker', 'product_info' => 'Anpassungen Verteilerkasten', 'start_date' => '2025-09-02', 'end_date' => '2025-09-02', 'amount' => 5, 'price' => 75.00, 'vatrate' => 20],
|
||||
]),
|
||||
'closingText' => 'Vielen Dank für Ihren Auftrag.', 'taxText' => ''
|
||||
],
|
||||
[
|
||||
'id' => 5, 'invoiceNumber' => 'RE-2025-005', 'customerName' => 'Creative Solutions', 'billingAddressId' => 5,
|
||||
'invoiceDate' => strtotime('2025-09-15'), 'dueDate' => strtotime('2025-09-29'), 'totalAmount' => 1920.00, 'status' => 'sent',
|
||||
'positions' => json_encode([
|
||||
['product_name' => 'Web-Entwicklung', 'product_info' => 'Umsetzung Landingpage "Herbst-Aktion"', 'start_date' => '2025-09-01', 'end_date' => '2025-09-12', 'amount' => 10, 'price' => 110.00, 'vatrate' => 20],
|
||||
['product_name' => 'Domain-Registrierung (.at)', 'product_info' => 'herbst-aktion.at', 'start_date' => '2025-09-01', 'end_date' => '2026-08-31', 'amount' => 1, 'price' => 500.00, 'vatrate' => 20],
|
||||
]),
|
||||
'closingText' => 'Wir freuen uns auf eine weiterhin gute Zusammenarbeit.', 'taxText' => ''
|
||||
],
|
||||
[
|
||||
'id' => 6, 'invoiceNumber' => 'RE-2025-006', 'customerName' => 'Logistik Express', 'billingAddressId' => 6,
|
||||
'invoiceDate' => strtotime('2025-08-28'), 'dueDate' => strtotime('2025-09-11'), 'totalAmount' => 3432.00, 'status' => 'paid',
|
||||
'positions' => json_encode([
|
||||
['product_name' => 'Software-Lizenz WMS Pro', 'product_info' => 'Jahreslizenz für 10 User', 'start_date' => '2025-09-01', 'end_date' => '2026-08-31', 'amount' => 1, 'price' => 2500.00, 'vatrate' => 20],
|
||||
['product_name' => 'Mitarbeiterschulung WMS', 'product_info' => 'Vor Ort am 27.08.2025', 'start_date' => '2025-08-27', 'end_date' => '2025-08-27', 'amount' => 4, 'price' => 90.00, 'vatrate' => 20],
|
||||
]),
|
||||
'closingText' => 'Vielen Dank für Ihren Auftrag.', 'taxText' => ''
|
||||
],
|
||||
[
|
||||
'id' => 7, 'invoiceNumber' => 'RE-2025-007', 'customerName' => 'Gastro Profi', 'billingAddressId' => 7,
|
||||
'invoiceDate' => strtotime('2025-09-10'), 'dueDate' => strtotime('2025-09-24'), 'totalAmount' => 2577.60, 'status' => 'draft',
|
||||
'positions' => json_encode([
|
||||
['product_name' => 'Kassensystem "GastroTouch"', 'product_info' => '2x Terminal, 1x Bondrucker', 'start_date' => '2025-09-09', 'end_date' => '2025-09-09', 'amount' => 2, 'price' => 899.00, 'vatrate' => 20],
|
||||
['product_name' => 'Installationspauschale', 'product_info' => 'Inkl. Einschulung', 'start_date' => '2025-09-09', 'end_date' => '2025-09-09', 'amount' => 1, 'price' => 350.00, 'vatrate' => 20],
|
||||
]),
|
||||
'closingText' => 'Bei Fragen stehen wir Ihnen gerne zur Verfügung.', 'taxText' => ''
|
||||
],
|
||||
[
|
||||
'id' => 8, 'invoiceNumber' => 'RE-2025-008', 'customerName' => 'Sicherheitsdienst Huber', 'billingAddressId' => 8,
|
||||
'invoiceDate' => strtotime('2025-09-01'), 'dueDate' => strtotime('2025-09-15'), 'totalAmount' => 1782.00, 'status' => 'sent',
|
||||
'positions' => json_encode([
|
||||
['product_name' => 'IP Kamera 4K Dome', 'product_info' => 'Modell SEC-4K-D', 'start_date' => '2025-08-29', 'end_date' => '2025-08-29', 'amount' => 8, 'price' => 180.00, 'vatrate' => 20],
|
||||
['product_name' => 'Monatliche Wartungspauschale', 'product_info' => 'September 2025', 'start_date' => '2025-09-01', 'end_date' => '2025-09-30', 'amount' => 1, 'price' => 45.00, 'vatrate' => 20],
|
||||
]),
|
||||
'closingText' => 'Wir freuen uns auf eine weiterhin gute Zusammenarbeit.', 'taxText' => ''
|
||||
],
|
||||
[
|
||||
'id' => 9, 'invoiceNumber' => 'RE-2025-009', 'customerName' => 'Praxis Dr. Eder', 'billingAddressId' => 9,
|
||||
'invoiceDate' => strtotime('2025-09-12'), 'dueDate' => strtotime('2025-09-26'), 'totalAmount' => 3090.00, 'status' => 'draft',
|
||||
'positions' => json_encode([
|
||||
['product_name' => 'Arbeitsstunden IT-Migration', 'product_info' => 'Serverumzug und Client-Setup', 'start_date' => '2025-09-11', 'end_date' => '2025-09-11', 'amount' => 5, 'price' => 95.00, 'vatrate' => 20],
|
||||
['product_name' => 'Server-Hardware "MedServ"', 'product_info' => 'Spez. für Arztpraxen', 'start_date' => '2025-09-11', 'end_date' => '2025-09-11', 'amount' => 1, 'price' => 1800.00, 'vatrate' => 20],
|
||||
['product_name' => 'Datensicherungslösung "CloudSafe"', 'product_info' => 'Einrichtungspauschale', 'start_date' => '2025-09-11', 'end_date' => '2025-09-11', 'amount' => 1, 'price' => 300.00, 'vatrate' => 20],
|
||||
]),
|
||||
'closingText' => 'Bei Fragen stehen wir Ihnen gerne zur Verfügung.', 'taxText' => ''
|
||||
],
|
||||
[
|
||||
'id' => 10, 'invoiceNumber' => 'RE-2025-010', 'customerName' => 'Architekturbüro Planweit', 'billingAddressId' => 10,
|
||||
'invoiceDate' => strtotime('2025-09-08'), 'dueDate' => strtotime('2025-09-22'), 'totalAmount' => 357.60, 'status' => 'paid',
|
||||
'positions' => json_encode([
|
||||
['product_name' => 'Plotter Service', 'product_info' => 'Wartung und Reinigung', 'start_date' => '2025-09-04', 'end_date' => '2025-09-04', 'amount' => 1, 'price' => 250.00, 'vatrate' => 20],
|
||||
['product_name' => 'Netzwerkkabel Cat7', 'product_info' => 'Pro Meter', 'start_date' => '2025-09-04', 'end_date' => '2025-09-04', 'amount' => 40, 'price' => 1.20, 'vatrate' => 20],
|
||||
]),
|
||||
'closingText' => 'Vielen Dank für Ihren Auftrag.', 'taxText' => ''
|
||||
],
|
||||
];
|
||||
|
||||
return $mockData;
|
||||
}
|
||||
class ManualInvoiceModel {
|
||||
public $invoice_number;
|
||||
public $invoice_date;
|
||||
public $owner_id;
|
||||
public $billingaddress_id;
|
||||
public $customer_number;
|
||||
public $fibu_account_number;
|
||||
public $fibu_payment_due;
|
||||
public $fibu_payment_skonto;
|
||||
public $fibu_payment_skonto_rate;
|
||||
public $sepa_date;
|
||||
public $sepa_id;
|
||||
public $sepa_last_date;
|
||||
public $fibu_cost_area;
|
||||
public $fibu_cost_account;
|
||||
public $fibu_cost_account_legacy;
|
||||
public $fibu_taxcode;
|
||||
public $tax_text;
|
||||
public $company;
|
||||
public $firstname;
|
||||
public $lastname;
|
||||
public $street;
|
||||
public $zip;
|
||||
public $city;
|
||||
public $country;
|
||||
public $email;
|
||||
public $uid;
|
||||
public $billing_type;
|
||||
public $billing_delivery;
|
||||
public $bank_account_bank;
|
||||
public $bank_account_owner;
|
||||
public $bank_account_iban;
|
||||
public $bank_account_bic;
|
||||
public $total;
|
||||
public $total_gross;
|
||||
public $vatgroup_id;
|
||||
public $bmd_export_date;
|
||||
public $date_delivered;
|
||||
public $create_by;
|
||||
public $edit_by;
|
||||
public $create;
|
||||
public $edit;
|
||||
|
||||
|
||||
class ManualInvoiceModel extends TTCrudBaseModel {
|
||||
public int $id;
|
||||
public ?string $invoiceNumber;
|
||||
public ?int $invoiceDate;
|
||||
public ?int $dueDate;
|
||||
public int $billingAddressId;
|
||||
public ?string $customerName;
|
||||
public ?float $totalAmount;
|
||||
public string $status;
|
||||
public string $positions;
|
||||
public string $closingText;
|
||||
public string $taxText;
|
||||
public static function create($data) {
|
||||
$invoice = new ManualInvoice();
|
||||
|
||||
private static function applyFilter(array $data, array $filter): array {
|
||||
if (empty($filter)) {
|
||||
return $data;
|
||||
$me = new User();
|
||||
$me->loadMe();
|
||||
|
||||
// Set audit fields
|
||||
$invoice->create_by = $me->id;
|
||||
$invoice->edit_by = $me->id;
|
||||
$invoice->create = time();
|
||||
$invoice->edit = time();
|
||||
|
||||
// Set invoice fields
|
||||
foreach($data as $field => $value) {
|
||||
if(property_exists($invoice, $field) && $field != 'id') {
|
||||
$invoice->$field = $value;
|
||||
}
|
||||
}
|
||||
return array_filter($data, function ($row) use ($filter) {
|
||||
foreach ($filter as $key => $value) {
|
||||
if (!isset($row[$key]) || empty($value)) {
|
||||
continue;
|
||||
}
|
||||
if (is_array($value)) { // Handle date ranges
|
||||
if (isset($value['from']) && $row[$key] < $value['from']) return false;
|
||||
if (isset($value['to']) && $row[$key] > $value['to']) return false;
|
||||
} else if (is_array($row[$key])) {
|
||||
if (!in_array($value, $row[$key])) return false;
|
||||
} else if (stripos($row[$key], $value) === false) {
|
||||
return false;
|
||||
|
||||
// Set defaults
|
||||
if (!$invoice->billing_type) {
|
||||
$invoice->billing_type = 'invoice';
|
||||
}
|
||||
if (!$invoice->billing_delivery) {
|
||||
$invoice->billing_delivery = 'email';
|
||||
}
|
||||
if (!$invoice->total) {
|
||||
$invoice->total = 0;
|
||||
}
|
||||
if (!$invoice->total_gross) {
|
||||
$invoice->total_gross = 0;
|
||||
}
|
||||
if (!$invoice->vatgroup_id) {
|
||||
$invoice->vatgroup_id = 1; // Default VAT group
|
||||
}
|
||||
if (!$invoice->owner_id) {
|
||||
$invoice->owner_id = 0;
|
||||
}
|
||||
if (!$invoice->billingaddress_id) {
|
||||
$invoice->billingaddress_id = 0;
|
||||
}
|
||||
if (!$invoice->customer_number) {
|
||||
$invoice->customer_number = 0;
|
||||
}
|
||||
|
||||
if ($invoice->save()) {
|
||||
return $invoice->id;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function getNextInvoiceNumber() {
|
||||
$last_invoice_num = self::getLastInvoiceNumber();
|
||||
|
||||
if(!$last_invoice_num) {
|
||||
return "MRN".date("Y")."-X000001";
|
||||
}
|
||||
|
||||
$year_part = 0;
|
||||
$num_part = 0;
|
||||
|
||||
$m = [];
|
||||
if(preg_match('/^MRN(\d+)-X(\d+)$/', $last_invoice_num, $m)) {
|
||||
if(array_key_exists(1, $m)) {
|
||||
$year_part = $m[1];
|
||||
if(array_key_exists(2, $m)) {
|
||||
$num_part = $m[2];
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
if(!$year_part || !$num_part) {
|
||||
return "MRN".date("Y")."-X000001";
|
||||
}
|
||||
|
||||
if(date("Y") == $year_part) {
|
||||
$new_year_part = $year_part;
|
||||
$new_num_part = $num_part + 1;
|
||||
} else {
|
||||
$new_year_part = date("Y");
|
||||
$new_num_part = 1;
|
||||
}
|
||||
|
||||
$new_invoice_num = "MRN$new_year_part-X".str_pad($new_num_part,"6", "0", STR_PAD_LEFT);
|
||||
return $new_invoice_num;
|
||||
}
|
||||
|
||||
public static function getAll($filter = [], $limit = null, $offset = 0, $order = ["key" => null]): array
|
||||
public static function getLastInvoiceNumber() {
|
||||
$last_invoice = self::getLast(["invoice_number" => true]);
|
||||
if(!$last_invoice || !$last_invoice->invoice_number) {
|
||||
return false;
|
||||
}
|
||||
return $last_invoice->invoice_number;
|
||||
}
|
||||
|
||||
{
|
||||
$mockData = getMockData();
|
||||
$filteredData = self::applyFilter($mockData, $filter);
|
||||
public static function getAll($filter = [], $limit = null, $offset = 0, $order = ["key" => null]) {
|
||||
$items = [];
|
||||
|
||||
$db = FronkDB::singleton();
|
||||
|
||||
$where = self::getSqlFilter($filter);
|
||||
$sql = "SELECT ManualInvoice.* FROM ManualInvoice WHERE $where";
|
||||
|
||||
if ($order['key'] !== null) {
|
||||
usort($filteredData, function ($a, $b) use ($order) {
|
||||
if ($a[$order['key']] == $b[$order['key']]) return 0;
|
||||
if ($order['order'] === 'ASC') {
|
||||
return $a[$order['key']] < $b[$order['key']] ? -1 : 1;
|
||||
} else {
|
||||
return $a[$order['key']] > $b[$order['key']] ? -1 : 1;
|
||||
}
|
||||
});
|
||||
$orderDir = isset($order['order']) ? $order['order'] : 'ASC';
|
||||
$sql .= " ORDER BY " . $order['key'] . " " . $orderDir;
|
||||
} else {
|
||||
$sql .= " ORDER BY invoice_number";
|
||||
}
|
||||
|
||||
if ($limit !== null) {
|
||||
return array_slice($filteredData, $offset, $limit);
|
||||
if($limit !== null) {
|
||||
$sql .= " LIMIT " . intval($offset) . ", " . intval($limit);
|
||||
}
|
||||
return $filteredData;
|
||||
|
||||
$res = $db->query($sql);
|
||||
if($db->num_rows($res)) {
|
||||
while($data = $db->fetch_object($res)) {
|
||||
$items[] = new ManualInvoice($data);
|
||||
}
|
||||
}
|
||||
return $items;
|
||||
}
|
||||
|
||||
public static function count($filter = []): int {
|
||||
$mockData = getMockData();
|
||||
return count(self::applyFilter($mockData, $filter));
|
||||
}
|
||||
public static function getFirst($filter) {
|
||||
$db = FronkDB::singleton();
|
||||
|
||||
public static function get($id) {
|
||||
$mockData = getMockData();
|
||||
foreach ($mockData as $row)
|
||||
if ($row['id'] == $id)
|
||||
return new self($row);
|
||||
$where = self::getSqlFilter($filter);
|
||||
$sql = "SELECT ManualInvoice.* FROM ManualInvoice WHERE $where ORDER BY invoice_number LIMIT 1";
|
||||
|
||||
$res = $db->query($sql);
|
||||
if($db->num_rows($res)) {
|
||||
$data = $db->fetch_object($res);
|
||||
$item = new ManualInvoice($data);
|
||||
if($item->id) {
|
||||
return $item;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function create($data) {
|
||||
error_log("ManualInvoiceModel::create called with: " . json_encode($data));
|
||||
return time();
|
||||
public static function getLast($filter) {
|
||||
$db = FronkDB::singleton();
|
||||
|
||||
$where = self::getSqlFilter($filter);
|
||||
$sql = "SELECT ManualInvoice.* FROM ManualInvoice WHERE $where ORDER BY invoice_number DESC LIMIT 1";
|
||||
|
||||
mfLoghandler::singleton()->debug($sql);
|
||||
|
||||
$res = $db->query($sql);
|
||||
if($db->num_rows($res)) {
|
||||
$data = $db->fetch_object($res);
|
||||
$item = new ManualInvoice($data);
|
||||
if($item->id) {
|
||||
return $item;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function count($filter = []) {
|
||||
$db = FronkDB::singleton();
|
||||
|
||||
$where = self::getSqlFilter($filter);
|
||||
$sql = "SELECT COUNT(*) as cnt FROM ManualInvoice WHERE $where";
|
||||
|
||||
mfLoghandler::singleton()->debug($sql);
|
||||
|
||||
$res = $db->query($sql);
|
||||
if($db->num_rows($res)) {
|
||||
$data = $db->fetch_object($res);
|
||||
return $data->cnt;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
public static function search($filter, $limit = false, $order = false) {
|
||||
$items = [];
|
||||
|
||||
if(!$order) {
|
||||
$order = "invoice_number ASC";
|
||||
}
|
||||
|
||||
$db = FronkDB::singleton();
|
||||
|
||||
$where = self::getSqlFilter($filter);
|
||||
$sql = "SELECT ManualInvoice.* FROM ManualInvoice WHERE $where ORDER BY $order";
|
||||
|
||||
if(is_array($limit) && count($limit)) {
|
||||
if(is_numeric($limit['start']) && is_numeric($limit['count'])) {
|
||||
$sql .= " LIMIT ".$limit['start'].", ".$limit['count'];
|
||||
} elseif(is_numeric($limit['count'])) {
|
||||
$sql .= " LIMIT ".$limit['count'];
|
||||
}
|
||||
}
|
||||
|
||||
mfLoghandler::singleton()->debug($sql);
|
||||
|
||||
$res = $db->query($sql);
|
||||
if($db->num_rows($res)) {
|
||||
while($data = $db->fetch_object($res)) {
|
||||
$items[$data->id] = new ManualInvoice($data);
|
||||
}
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
private static function getSqlFilter($filter) {
|
||||
$where = "1=1 ";
|
||||
$db = FronkDB::singleton();
|
||||
|
||||
if(array_key_exists("id", $filter)) {
|
||||
$id = $filter['id'];
|
||||
if(is_numeric($id)) {
|
||||
$where .= " AND ManualInvoice.id like '%$id%'";
|
||||
}
|
||||
}
|
||||
|
||||
if(array_key_exists("invoice_number", $filter)) {
|
||||
$invoice_number = $filter['invoice_number'];
|
||||
if($invoice_number === true) {
|
||||
$where .= " AND ManualInvoice.invoice_number IS NOT NULL AND ManualInvoice.invoice_number <> ''";
|
||||
} elseif($invoice_number) {
|
||||
$invoice_number = $db->escape($invoice_number);
|
||||
$where .= " AND ManualInvoice.invoice_number='$invoice_number'";
|
||||
} elseif($invoice_number === null || $invoice_number === false) {
|
||||
$where .= " AND ManualInvoice.invoice_number IS NULL";
|
||||
}
|
||||
}
|
||||
|
||||
if(array_key_exists("invoice_number%", $filter)) {
|
||||
$invoice_number = $filter['invoice_number%'];
|
||||
if($invoice_number) {
|
||||
$where .= " AND ManualInvoice.invoice_number LIKE '%$invoice_number%'";
|
||||
}
|
||||
}
|
||||
|
||||
if(array_key_exists("invoice_date", $filter)) {
|
||||
$invoice_date = $filter['invoice_date'];
|
||||
if($invoice_date) {
|
||||
$where .= " AND ManualInvoice.invoice_date='$invoice_date'";
|
||||
} elseif($invoice_date === null || $invoice_date === false) {
|
||||
$where .= " AND ManualInvoice.invoice_date IS NULL";
|
||||
}
|
||||
}
|
||||
|
||||
if(array_key_exists("invoice_date>=", $filter)) {
|
||||
$invoice_date = $db->escape($filter['invoice_date>=']);
|
||||
if($invoice_date) {
|
||||
$where .= " AND ManualInvoice.invoice_date >= '$invoice_date'";
|
||||
}
|
||||
}
|
||||
|
||||
if(array_key_exists("invoice_date<=", $filter)) {
|
||||
$invoice_date = $db->escape($filter['invoice_date<=']);
|
||||
if($invoice_date) {
|
||||
$where .= " AND ManualInvoice.invoice_date <= '$invoice_date'";
|
||||
}
|
||||
}
|
||||
|
||||
if(array_key_exists("owner_id", $filter)) {
|
||||
$owner_id = $filter['owner_id'];
|
||||
if(is_numeric($owner_id)) {
|
||||
$where .= " AND ManualInvoice.owner_id=$owner_id";
|
||||
}
|
||||
}
|
||||
|
||||
if(array_key_exists("billingaddress_id", $filter)) {
|
||||
$billingaddress_id = $filter['billingaddress_id'];
|
||||
if(is_numeric($billingaddress_id)) {
|
||||
$where .= " AND ManualInvoice.billingaddress_id=$billingaddress_id";
|
||||
}
|
||||
}
|
||||
|
||||
if(array_key_exists("customer_number", $filter)) {
|
||||
$customer_number = $filter['customer_number'];
|
||||
if(is_numeric($customer_number)) {
|
||||
$where .= " AND ManualInvoice.customer_number LIKE $customer_number";
|
||||
}
|
||||
}
|
||||
|
||||
if(array_key_exists("billing_type", $filter)) {
|
||||
$billing_type = $db->escape($filter['billing_type']);
|
||||
if($billing_type) {
|
||||
$where .= " AND ManualInvoice.billing_type LIKE '$billing_type'";
|
||||
}
|
||||
}
|
||||
|
||||
if(array_key_exists("billing_delivery", $filter)) {
|
||||
$billing_delivery = $db->escape($filter['billing_delivery']);
|
||||
if($billing_delivery) {
|
||||
$where .= " AND ManualInvoice.billing_delivery LIKE '$billing_delivery'";
|
||||
}
|
||||
}
|
||||
|
||||
if(array_key_exists("add-where", $filter)) {
|
||||
$where .= " ".$filter['add-where'];
|
||||
}
|
||||
|
||||
return $where;
|
||||
}
|
||||
|
||||
public static function get($id) {
|
||||
return self::getFirst(["id" => $id]);
|
||||
}
|
||||
|
||||
public static function update($data) {
|
||||
error_log("ManualInvoiceModel::update called with: " . json_encode($data));
|
||||
return 1;
|
||||
if (!isset($data['id'])) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$invoice = new ManualInvoice($data['id']);
|
||||
if (!$invoice->id) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$me = new User();
|
||||
$me->loadMe();
|
||||
|
||||
// Set audit fields
|
||||
$invoice->edit_by = $me->id;
|
||||
$invoice->edit = time();
|
||||
|
||||
// Update fields
|
||||
foreach($data as $field => $value) {
|
||||
if(property_exists($invoice, $field) && $field != 'id' && $field != 'create' && $field != 'create_by') {
|
||||
$invoice->$field = $value;
|
||||
}
|
||||
}
|
||||
|
||||
if ($invoice->save()) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
public static function delete($id) {
|
||||
error_log("ManualInvoiceModel::delete called with ID: " . $id);
|
||||
return 1;
|
||||
$invoice = new ManualInvoice($id);
|
||||
if (!$invoice->id) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Delete positions first
|
||||
$positions = ManualInvoicepositionModel::search(['manualinvoice_id' => $id]);
|
||||
foreach ($positions as $pos) {
|
||||
$pos->delete();
|
||||
}
|
||||
|
||||
// Delete invoice
|
||||
if ($invoice->delete()) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
35
application/ManualInvoiceposition/ManualInvoiceposition.php
Normal file
35
application/ManualInvoiceposition/ManualInvoiceposition.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
class ManualInvoiceposition extends mfBaseModel {
|
||||
|
||||
public function getOption($key) {
|
||||
if(!$this->options) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$options = json_decode($this->options, true);
|
||||
if(!$options || !is_array($options)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if(array_key_exists($key, $options)) {
|
||||
return $options[$key];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function setOption($key, $value) {
|
||||
if(!$this->options) {
|
||||
$this->options = json_encode([]);
|
||||
}
|
||||
|
||||
$options = json_decode($this->options, true);
|
||||
if(!is_array($options)) {
|
||||
$options = [];
|
||||
}
|
||||
|
||||
$options[$key] = $value;
|
||||
$this->options = json_encode($options);
|
||||
}
|
||||
}
|
||||
160
application/ManualInvoiceposition/ManualInvoicepositionModel.php
Normal file
160
application/ManualInvoiceposition/ManualInvoicepositionModel.php
Normal file
@@ -0,0 +1,160 @@
|
||||
<?php
|
||||
|
||||
class ManualInvoicepositionModel {
|
||||
public $manualinvoice_id;
|
||||
public $billing_id;
|
||||
public $contract_id;
|
||||
public $start_date;
|
||||
public $end_date;
|
||||
public $matchcode;
|
||||
public $product_id;
|
||||
public $product_name;
|
||||
public $product_info;
|
||||
public $amount;
|
||||
public $price;
|
||||
public $price_total;
|
||||
public $price_gross;
|
||||
public $vatrate;
|
||||
public $fibu_cost_account;
|
||||
public $fibu_cost_account_legacy;
|
||||
public $fibu_taxcode;
|
||||
public $billing_period;
|
||||
public $options;
|
||||
|
||||
public $create_by;
|
||||
public $edit_by;
|
||||
public $create;
|
||||
public $edit;
|
||||
|
||||
|
||||
public static function create(Array $data) {
|
||||
$model = new ManualInvoiceposition();
|
||||
|
||||
foreach($data as $field => $value) {
|
||||
if(property_exists(get_called_class(), $field)) {
|
||||
$model ->$field = $value;
|
||||
}
|
||||
}
|
||||
|
||||
$me = new User();
|
||||
$me->loadMe();
|
||||
|
||||
if($model->create_by === null) {
|
||||
$model->create_by = $me->id;
|
||||
}
|
||||
if($model->edit_by === null) {
|
||||
$model->edit_by = $me->id;
|
||||
}
|
||||
|
||||
return $model;
|
||||
}
|
||||
|
||||
public static function getAll() {
|
||||
$items = [];
|
||||
|
||||
$db = FronkDB::singleton();
|
||||
|
||||
$res = $db->select("ManualInvoiceposition", "*", "1 = 1 ORDER BY manualinvoice_id,contract_id,start_date,matchcode");
|
||||
if($db->num_rows($res)) {
|
||||
while($data = $db->fetch_object($res)) {
|
||||
$items[] = new ManualInvoiceposition($data);
|
||||
}
|
||||
}
|
||||
return $items;
|
||||
|
||||
}
|
||||
|
||||
public static function getFirst($filter) {
|
||||
$db = FronkDB::singleton();
|
||||
|
||||
$where = self::getSqlFilter($filter);
|
||||
$sql = "SELECT * FROM ManualInvoiceposition
|
||||
WHERE $where
|
||||
ORDER BY manualinvoice_id,contract_id,start_date,matchcode LIMIT 1";
|
||||
|
||||
$res = $db->query($sql);
|
||||
if($db->num_rows($res)) {
|
||||
$data = $db->fetch_object($res);
|
||||
$item = new ManualInvoiceposition($data);
|
||||
if($item->id) {
|
||||
return $item;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function count($filter) {
|
||||
$db = FronkDB::singleton();
|
||||
|
||||
$where = self::getSqlFilter($filter);
|
||||
$sql = "SELECT COUNT(*) as cnt FROM ManualInvoiceposition WHERE $where";
|
||||
|
||||
$res = $db->query($sql);
|
||||
if($db->num_rows($res)) {
|
||||
$data = $db->fetch_object($res);
|
||||
return $data->cnt;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
public static function search($filter, $limit = false, $order = false) {
|
||||
$items = [];
|
||||
|
||||
if(!$order) {
|
||||
$order = "manualinvoice_id,contract_id,id ASC";
|
||||
}
|
||||
|
||||
$db = FronkDB::singleton();
|
||||
|
||||
$where = self::getSqlFilter($filter);
|
||||
$sql = "SELECT * FROM ManualInvoiceposition WHERE $where ORDER BY $order";
|
||||
|
||||
if(is_array($limit) && count($limit)) {
|
||||
if(is_numeric($limit['start']) && is_numeric($limit['count'])) {
|
||||
$sql .= " LIMIT ".$limit['start'].", ".$limit['count'];
|
||||
} elseif(is_numeric($limit['count'])) {
|
||||
$sql .= " LIMIT ".$limit['count'];
|
||||
}
|
||||
}
|
||||
|
||||
mfLoghandler::singleton()->debug($sql);
|
||||
|
||||
$res = $db->query($sql);
|
||||
if($db->num_rows($res)) {
|
||||
while($data = $db->fetch_object($res)) {
|
||||
$items[$data->id] = new ManualInvoiceposition($data);
|
||||
}
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
private static function getSqlFilter($filter) {
|
||||
$where = "1=1 ";
|
||||
|
||||
$db = FronkDB::singleton();
|
||||
|
||||
if(array_key_exists("id", $filter)) {
|
||||
$id = $filter['id'];
|
||||
if(is_numeric($id)) {
|
||||
$where .= " AND ManualInvoiceposition.id = $id";
|
||||
}
|
||||
}
|
||||
|
||||
if(array_key_exists("manualinvoice_id", $filter)) {
|
||||
$manualinvoice_id = $filter['manualinvoice_id'];
|
||||
if(is_numeric($manualinvoice_id)) {
|
||||
$where .= " AND ManualInvoiceposition.manualinvoice_id=$manualinvoice_id";
|
||||
}
|
||||
}
|
||||
|
||||
if(array_key_exists("add-where", $filter)) {
|
||||
$where .= " ".$filter['add-where'];
|
||||
}
|
||||
|
||||
return $where;
|
||||
}
|
||||
|
||||
}
|
||||
110
db/migrations/20251201120000_create_manual_invoice_tables.php
Normal file
110
db/migrations/20251201120000_create_manual_invoice_tables.php
Normal file
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use Phinx\Migration\AbstractMigration;
|
||||
|
||||
final class CreateManualInvoiceTables extends AbstractMigration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if($this->getEnvironment() == "thetool") {
|
||||
// Create ManualInvoice table
|
||||
$manualInvoice = $this->table("ManualInvoice");
|
||||
|
||||
$manualInvoice->addColumn("invoice_number", "string", ["null" => true, "default" => null]);
|
||||
$manualInvoice->addColumn("invoice_date", "integer", ["default" => 0]);
|
||||
$manualInvoice->addColumn("owner_id", "integer", ["null" => false]);
|
||||
$manualInvoice->addColumn("billingaddress_id", "integer", ["null" => false]);
|
||||
$manualInvoice->addColumn("customer_number", "integer", ["null" => false]);
|
||||
$manualInvoice->addColumn("fibu_account_number", "integer", ["null" => true, "default" => null]);
|
||||
$manualInvoice->addColumn("fibu_payment_due", "integer", ["null" => true, "default" => null]);
|
||||
$manualInvoice->addColumn("fibu_payment_skonto", "integer", ["null" => false, "default" => 0]);
|
||||
$manualInvoice->addColumn("fibu_payment_skonto_rate", "integer", ["null" => false, "default" => 0]);
|
||||
$manualInvoice->addColumn("sepa_date", "date", ["null" => true, "default" => null]);
|
||||
$manualInvoice->addColumn("sepa_id", "string", ["null" => true, "default" => null, "length" => 255]);
|
||||
$manualInvoice->addColumn("sepa_last_date", "date", ["null" => true, "default" => null]);
|
||||
$manualInvoice->addColumn("fibu_cost_area", "string", ["null" => true, "default" => null, "length" => 255]);
|
||||
$manualInvoice->addColumn("fibu_cost_account", "integer", ["null" => true, "default" => null]);
|
||||
$manualInvoice->addColumn("fibu_cost_account_legacy", "integer", ["null" => true, "default" => null]);
|
||||
$manualInvoice->addColumn("fibu_taxcode", "integer", ["null" => true, "default" => null]);
|
||||
$manualInvoice->addColumn("tax_text", "string", ["null" => true, "default" => null, "length" => 255]);
|
||||
$manualInvoice->addColumn("company", "string", ["null" => true, "default" => null, "length" => 1024]);
|
||||
$manualInvoice->addColumn("firstname", "string", ["null" => true, "default" => null, "length" => 1024]);
|
||||
$manualInvoice->addColumn("lastname", "string", ["null" => true, "default" => null, "length" => 1024]);
|
||||
$manualInvoice->addColumn("street", "string", ["null" => false, "length" => 1024]);
|
||||
$manualInvoice->addColumn("zip", "string", ["null" => false, "length" => 1024]);
|
||||
$manualInvoice->addColumn("city", "string", ["null" => false, "length" => 1024]);
|
||||
$manualInvoice->addColumn("country", "string", ["null" => true, "default" => null, "length" => 1024]);
|
||||
$manualInvoice->addColumn("email", "string", ["null" => true, "default" => null, "length" => 1024]);
|
||||
$manualInvoice->addColumn("uid", "string", ["null" => true, "default" => null, "length" => 1024]);
|
||||
$manualInvoice->addColumn("billing_type", "enum", ["null" => false, "values" => "invoice,sepa"]);
|
||||
$manualInvoice->addColumn("billing_delivery", "enum", ["null" => false, "values" => "email,paper"]);
|
||||
$manualInvoice->addColumn("bank_account_bank", "string", ["null" => true, "default" => null, "length" => 255]);
|
||||
$manualInvoice->addColumn("bank_account_owner", "string", ["null" => true, "default" => null, "length" => 255]);
|
||||
$manualInvoice->addColumn("bank_account_iban", "string", ["null" => true, "default" => null, "length" => 255]);
|
||||
$manualInvoice->addColumn("bank_account_bic", "string", ["null" => true, "default" => null, "length" => 255]);
|
||||
$manualInvoice->addColumn("total", "decimal", ["null" => false, "precision" => 14, "scale" => 4]);
|
||||
$manualInvoice->addColumn("total_gross", "decimal", ["null" => false, "precision" => 14, "scale" => 4]);
|
||||
$manualInvoice->addColumn("vatgroup_id", "integer", ["null" => false]);
|
||||
$manualInvoice->addColumn("bmd_export_date", "integer", ["null" => true, "default" => null]);
|
||||
$manualInvoice->addColumn("date_delivered", "integer", ["null" => true, "default" => null]);
|
||||
$manualInvoice->addColumn("create_by", "integer", ["null" => false]);
|
||||
$manualInvoice->addColumn("edit_by", "integer", ["null" => false]);
|
||||
$manualInvoice->addColumn("create", "integer", ["null" => false]);
|
||||
$manualInvoice->addColumn("edit", "integer", ["null" => false]);
|
||||
|
||||
$manualInvoice->addIndex(["invoice_number"], ["name" => "invoice_number"]);
|
||||
$manualInvoice->addIndex(["invoice_date"], ["name" => "invoice_date"]);
|
||||
$manualInvoice->addIndex(["owner_id"], ["name" => "owner_id"]);
|
||||
$manualInvoice->addIndex(["billingaddress_id"], ["name" => "billingaddress_id"]);
|
||||
$manualInvoice->addIndex(["customer_number"], ["name" => "customer_number"]);
|
||||
|
||||
$manualInvoice->create();
|
||||
|
||||
// Create ManualInvoiceposition table
|
||||
$manualInvoicePosition = $this->table("ManualInvoiceposition");
|
||||
|
||||
$manualInvoicePosition->addColumn("manualinvoice_id", "integer", ["null" => true, "default" => null]);
|
||||
$manualInvoicePosition->addColumn("billing_id", "integer", ["null" => true, "default" => null]);
|
||||
$manualInvoicePosition->addColumn("contract_id", "integer", ["null" => false]);
|
||||
$manualInvoicePosition->addColumn("start_date", "date", ["null" => false]);
|
||||
$manualInvoicePosition->addColumn("end_date", "date", ["null" => true, "default" => null]);
|
||||
$manualInvoicePosition->addColumn("matchcode", "string", ["null" => true, "default" => null, "length" => 255]);
|
||||
$manualInvoicePosition->addColumn("product_id", "integer", ["null" => false]);
|
||||
$manualInvoicePosition->addColumn("product_name", "string", ["null" => false, "length" => 255]);
|
||||
$manualInvoicePosition->addColumn("product_info", "text", ["null" => true, "default" => null]);
|
||||
$manualInvoicePosition->addColumn("amount", "decimal", ["null" => false, "precision" => 9, "scale" => 6]);
|
||||
$manualInvoicePosition->addColumn("price", "decimal", ["null" => false, "precision" => 14, "scale" => 4]);
|
||||
$manualInvoicePosition->addColumn("price_total", "decimal", ["null" => false, "precision" => 14, "scale" => 4]);
|
||||
$manualInvoicePosition->addColumn("price_gross", "decimal", ["null" => false, "precision" => 14, "scale" => 4]);
|
||||
$manualInvoicePosition->addColumn("vatrate", "decimal", ["null" => false, "default" => 0, "precision" => 6, "scale" => 2]);
|
||||
$manualInvoicePosition->addColumn("fibu_cost_account", "integer", ["null" => true, "default" => null]);
|
||||
$manualInvoicePosition->addColumn("fibu_cost_account_legacy", "integer", ["null" => true, "default" => null]);
|
||||
$manualInvoicePosition->addColumn("fibu_taxcode", "integer", ["null" => true, "default" => null]);
|
||||
$manualInvoicePosition->addColumn("billing_period", "integer", ["null" => false, "default" => 0]);
|
||||
$manualInvoicePosition->addColumn("options", "text", ["null" => true, "default" => null, "limit" => \Phinx\Db\Adapter\MysqlAdapter::TEXT_LONG]);
|
||||
$manualInvoicePosition->addColumn("create_by", "integer", ["null" => false]);
|
||||
$manualInvoicePosition->addColumn("edit_by", "integer", ["null" => false]);
|
||||
$manualInvoicePosition->addColumn("create", "integer", ["null" => false]);
|
||||
$manualInvoicePosition->addColumn("edit", "integer", ["null" => false]);
|
||||
|
||||
$manualInvoicePosition->create();
|
||||
}
|
||||
|
||||
if($this->getEnvironment() == "addressdb") {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if($this->getEnvironment() == "thetool") {
|
||||
$this->table("ManualInvoiceposition")->drop()->save();
|
||||
$this->table("ManualInvoice")->drop()->save();
|
||||
}
|
||||
|
||||
if($this->getEnvironment() == "addressdb") {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,10 +28,40 @@
|
||||
.invoice-preview-pane {
|
||||
flex: 1 1 auto;
|
||||
background-color: #525659;
|
||||
padding: 2rem;
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pdf-preview-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pdf-preview-container object {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.pdf-loading {
|
||||
text-align: center;
|
||||
color: white;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.pdf-loading i {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.pdf-loading p {
|
||||
font-size: 1.2rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.info-bar {
|
||||
|
||||
@@ -3,18 +3,28 @@ Vue.component('manual-invoice', {
|
||||
<tt-card>
|
||||
<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="Test Prefill & Reload" icon="fas fa-magic" @click="testPrefill" additional-class="btn-info"/>
|
||||
</div>
|
||||
|
||||
<tt-table-crud
|
||||
ref="table"
|
||||
emit-edit
|
||||
@edit="openModal($event)">
|
||||
<template v-slot:totalamount="{ row }">
|
||||
{{ formatPrice(row.totalAmount) }}
|
||||
<template v-slot:total="{ row }">
|
||||
{{ formatPrice(row.total) }}
|
||||
</template>
|
||||
<template v-slot:invoicedate="{ row }">
|
||||
{{ formatDate(row.invoiceDate) }}
|
||||
<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:customerName="{ row }">
|
||||
{{ row.customerName }}
|
||||
</template>
|
||||
<template v-slot:actions="{ row }">
|
||||
<button class="btn btn-sm btn-primary" @click="downloadPdf(row.id)" title="PDF herunterladen">
|
||||
<i class="fas fa-file-pdf"></i>
|
||||
</button>
|
||||
</template>
|
||||
</tt-table-crud>
|
||||
|
||||
@@ -32,19 +42,6 @@ Vue.component('manual-invoice', {
|
||||
editingInvoiceData: null,
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
const prefillData = localStorage.getItem('ManualInvoice_create');
|
||||
if (prefillData) {
|
||||
try {
|
||||
this.editingInvoiceData = JSON.parse(prefillData);
|
||||
this.isModalOpen = true;
|
||||
} catch (e) {
|
||||
console.error("Failed to parse prefill data:", e);
|
||||
} finally {
|
||||
localStorage.removeItem('ManualInvoice_create');
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
openModal(invoice = null) {
|
||||
this.editingInvoiceData = invoice ? JSON.parse(JSON.stringify(invoice)) : null;
|
||||
@@ -55,30 +52,75 @@ Vue.component('manual-invoice', {
|
||||
this.editingInvoiceData = null;
|
||||
this.$refs.table.$refs.table.refreshTable();
|
||||
},
|
||||
handleSave(invoiceData) {
|
||||
console.log("--- INVOICE SAVED (DEMO) ---");
|
||||
console.log(JSON.parse(JSON.stringify(invoiceData)));
|
||||
window.notify('success', 'Rechnung in der Konsole geloggt!');
|
||||
this.closeModal();
|
||||
async handleSave(invoiceData) {
|
||||
try {
|
||||
// Calculate totals for each position
|
||||
const positions = invoiceData.positions.map(p => {
|
||||
const amount = parseFloat(p.amount) || 0;
|
||||
const price = parseFloat(p.price) || 0;
|
||||
const vatrate = parseFloat(p.vatrate) || 0;
|
||||
const price_total = amount * price;
|
||||
const price_gross = price_total * (1 + vatrate / 100);
|
||||
|
||||
return {
|
||||
...p,
|
||||
amount,
|
||||
price,
|
||||
vatrate,
|
||||
price_total,
|
||||
price_gross,
|
||||
product_id: 0,
|
||||
contract_id: 0,
|
||||
billing_id: 0,
|
||||
billing_period: 0
|
||||
};
|
||||
});
|
||||
|
||||
// Prepare invoice data
|
||||
const payload = {
|
||||
id: invoiceData.id || null,
|
||||
invoice_number: invoiceData.invoice_number,
|
||||
invoice_date: invoiceData.invoice_date,
|
||||
owner_id: invoiceData.owner_id || 0,
|
||||
billingaddress_id: invoiceData.billingaddress_id || 0,
|
||||
customer_number: invoiceData.customer_number || 0,
|
||||
company: invoiceData.company || '',
|
||||
firstname: invoiceData.firstname || '',
|
||||
lastname: invoiceData.lastname || '',
|
||||
street: invoiceData.street || '',
|
||||
zip: invoiceData.zip || '',
|
||||
city: invoiceData.city || '',
|
||||
country: invoiceData.country || 'Österreich',
|
||||
email: invoiceData.email || '',
|
||||
uid: invoiceData.uid || '',
|
||||
billing_type: invoiceData.billing_type || 'invoice',
|
||||
billing_delivery: 'email',
|
||||
tax_text: invoiceData.tax_text || '',
|
||||
fibu_payment_due: 14,
|
||||
fibu_account_number: invoiceData.fibu_account_number || 0,
|
||||
vatgroup_id: 1,
|
||||
positions: positions
|
||||
};
|
||||
|
||||
const url = invoiceData.id
|
||||
? window.TT_CONFIG.UPDATE_URL
|
||||
: window.TT_CONFIG.CREATE_URL;
|
||||
|
||||
const response = await axios.post(url, payload);
|
||||
|
||||
if (response.data.success) {
|
||||
window.notify('success', response.data.message || 'Rechnung erfolgreich gespeichert!');
|
||||
this.closeModal();
|
||||
} else {
|
||||
window.notify('error', response.data.message || 'Fehler beim Speichern der Rechnung');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving invoice:', error);
|
||||
window.notify('error', 'Fehler beim Speichern der Rechnung: ' + (error.response?.data?.message || error.message));
|
||||
}
|
||||
},
|
||||
testPrefill() {
|
||||
const mockInvoice = {
|
||||
id: null,
|
||||
invoiceNumber: `RE-${new Date().getFullYear()}-XXXX`,
|
||||
invoiceDate: moment().unix(),
|
||||
dueDate: moment().add(14, 'days').unix(),
|
||||
status: 'draft',
|
||||
billingAddressId: 1, // Example ID for autocomplete to fetch
|
||||
customer: {}, // Will be populated by watcher
|
||||
positions: [
|
||||
{ product_name: 'Stunden Techniker', product_info: 'Arbeiten an Server-Infrastruktur', start_date: moment().format('YYYY-MM-DD'), end_date: moment().format('YYYY-MM-DD'), amount: 3.5, price: 95.00, vatrate: 20 },
|
||||
{ product_name: 'Anfahrtspauschale', product_info: '', start_date: moment().format('YYYY-MM-DD'), end_date: moment().format('YYYY-MM-DD'), amount: 1, price: 45.00, vatrate: 20 }
|
||||
],
|
||||
closingText: 'Wir bedanken uns für die gute Zusammenarbeit.',
|
||||
taxText: ''
|
||||
};
|
||||
localStorage.setItem('ManualInvoice_create', JSON.stringify(mockInvoice));
|
||||
window.location.reload();
|
||||
downloadPdf(invoiceId) {
|
||||
window.location.href = `${window.TT_CONFIG.BASE_PATH}/ManualInvoice/downloadInvoicePdf?id=${invoiceId}`;
|
||||
},
|
||||
formatPrice(value) {
|
||||
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(value || 0);
|
||||
@@ -93,7 +135,7 @@ Vue.component('manual-invoice', {
|
||||
Vue.component('manual-invoice-modal', {
|
||||
props: ['initialData'],
|
||||
template: `
|
||||
<div class="manual-invoice-overlay" :class="overlayClasses" @keydown.ctrl.q.prevent="togglePreviewVisibility" tabindex="-1" ref="overlay">
|
||||
<div class="manual-invoice-overlay" :class="overlayClasses" tabindex="-1" ref="overlay">
|
||||
<div class="info-bar" v-if="!isLargeScreen">
|
||||
<i class="fas fa-info-circle mr-2"></i> Drücke <strong>STRG + Q</strong> um die Vorschau umzuschalten.
|
||||
</div>
|
||||
@@ -102,21 +144,21 @@ Vue.component('manual-invoice-modal', {
|
||||
<div class="editor-header">
|
||||
<h3>{{ isCreateMode ? 'Neue Rechnung' : 'Rechnung bearbeiten' }}</h3>
|
||||
<div class="editor-actions">
|
||||
<tt-button text="Speichern" icon="fas fa-save" @click="$emit('save', invoiceData)" additional-class="btn-success"/>
|
||||
<tt-button text="Speichern" icon="fas fa-save" @click="saveInvoice" additional-class="btn-success"/>
|
||||
<tt-button text="Schließen" icon="fas fa-times" @click="close" additional-class="btn-secondary"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="editor-content">
|
||||
<tt-card>
|
||||
<template v-slot:header><h5><i class="fas fa-user-tie mr-2"></i>Kunde</h5></template>
|
||||
<tt-autocomplete label="Kunde suchen" :api-url="customerApiUrl" v-model="invoiceData.billingAddressId" sm row />
|
||||
<tt-autocomplete label="Kunde suchen" :api-url="customerApiUrl" v-model="invoiceData.billingaddress_id" sm row />
|
||||
</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.invoiceNumber" sm/>
|
||||
<tt-date-picker label="Rechnungsdatum" v-model="invoiceData.invoiceDate" :date-range="false" sm/>
|
||||
<tt-date-picker label="Fälligkeitsdatum" v-model="invoiceData.dueDate" :date-range="false" sm/>
|
||||
<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-select label="Zahlungsart" v-model="invoiceData.billing_type" :options="billingTypeOptions" sm/>
|
||||
</div>
|
||||
</tt-card>
|
||||
<tt-card>
|
||||
@@ -125,100 +167,20 @@ Vue.component('manual-invoice-modal', {
|
||||
</tt-card>
|
||||
<tt-card>
|
||||
<template v-slot:header><h5><i class="fas fa-paragraph mr-2"></i>Texte</h5></template>
|
||||
<tt-textarea label="Schlusstext" v-model="invoiceData.closingText" rows="4"/>
|
||||
<tt-textarea label="Steuerhinweis (z.B. Reverse Charge)" v-model="invoiceData.taxText" rows="2"/>
|
||||
<tt-textarea label="Steuerhinweis (z.B. Reverse Charge)" v-model="invoiceData.tax_text" rows="2"/>
|
||||
</tt-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="invoice-preview-pane" v-show="isLargeScreen || showPreviewOnSmallScreen">
|
||||
<div class="invoice-preview-document">
|
||||
<div style="height: 50px; margin-bottom: 32px">
|
||||
<img alt="Xinon Logo" src="/assets/images/xinon-full.png" style="text-align:left;height: 85px;">
|
||||
</div>
|
||||
<table class="preview-header-table">
|
||||
<tr>
|
||||
<td class="customer-details">
|
||||
<div>{{ invoiceData.customer.company }}</div>
|
||||
<div>{{ invoiceData.customer.name }}</div>
|
||||
<div>{{ invoiceData.customer.street }}</div>
|
||||
<div>{{ invoiceData.customer.zip }} {{ invoiceData.customer.city }}</div>
|
||||
<div v-if="invoiceData.customer.country !== 'Österreich'">{{ invoiceData.customer.country }}</div>
|
||||
</td>
|
||||
<td class="invoice-details-cell">
|
||||
<table class="invoice-details-box">
|
||||
<tr><td>Kundennummer:</td><td>{{ selectedCustomerObject.customer_number || '-' }}</td></tr>
|
||||
<tr><td>Rechnungsnummer:</td><td>{{ invoiceData.invoiceNumber }}</td></tr>
|
||||
<tr><td>Belegdatum:</td><td>{{ formatDate(invoiceData.invoiceDate) }}</td></tr>
|
||||
<tr v-if="invoiceData.customer.uid"><td>Ihre UID:</td><td>{{ invoiceData.customer.uid }}</td></tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div class="separator"></div>
|
||||
<div class="preview-main">
|
||||
<h2 style="text-align: center; color: #005384; font-size: 1.5rem; margin-bottom: 1.5rem;">Ihre Rechnung vom {{ formatDate(invoiceData.invoiceDate) }}</h2>
|
||||
<table class="positions-table">
|
||||
<thead>
|
||||
<tr class="uneven">
|
||||
<th style="text-align: left; padding-left: 4pt;">Leistung / Produkt</th>
|
||||
<th style="text-align: center;">Zeitraum</th>
|
||||
<th style="text-align: right;">Preis</th>
|
||||
<th style="text-align: center;">Menge</th>
|
||||
<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>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="(p, index) in invoiceData.positions">
|
||||
<tr :class="{'uneven': index % 2 === 0}"> <td style="vertical-align: top; padding-left: 4pt;">
|
||||
<strong>{{ p.product_name }}</strong>
|
||||
<div v-if="p.product_info" class="matchcode">{{ p.product_info }}</div>
|
||||
</td>
|
||||
<td style="text-align: center; vertical-align: top;">{{ formatPeriod(p.start_date, p.end_date) }}</td>
|
||||
<td style="text-align: right; vertical-align: top;">{{ formatPrice(p.price) }}</td>
|
||||
<td style="text-align: center; vertical-align: top;">{{ p.amount }}</td>
|
||||
<td style="text-align: right; vertical-align: top;">{{ formatPrice((p.amount || 0) * (p.price || 0)) }}</td>
|
||||
<td style="text-align: right; vertical-align: top;">{{ p.vatrate }}%</td>
|
||||
<td style="text-align: right; padding-right: 4pt; vertical-align: top;">{{ formatPrice(((p.amount || 0) * (p.price || 0)) * (1 + (p.vatrate || 0) / 100)) }}</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="totals-section">
|
||||
<table class="totals-table">
|
||||
<tr class="netto">
|
||||
<th>Gesamtbetrag Netto:</th>
|
||||
<td>{{ formatPrice(totals.net) }} €</td>
|
||||
</tr>
|
||||
<tr class="ust" v-for="(vatValue, vatRate) in totals.vat" :key="vatRate">
|
||||
<th>+ Umsatzsteuer {{ vatRate }}%:</th>
|
||||
<td>{{ formatPrice(vatValue) }} €</td>
|
||||
</tr>
|
||||
<tr class="brutto">
|
||||
<th>Gesamtbetrag Brutto:</th>
|
||||
<td>{{ formatPrice(totals.gross) }} €</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="payment-info">
|
||||
<p v-if="invoiceData.taxText" style="font-weight: bold;">{{invoiceData.taxText}}</p>
|
||||
Bitte <b>überweisen</b> Sie den Rechnungsbetrag bis zum <b>{{ formatDate(invoiceData.dueDate) }}</b> auf folgendes Konto:<br />
|
||||
<b style="padding-left: 4pt;">IBAN: {{ bankDetails.iban }}</b><br />
|
||||
<b style="padding-left: 4pt;">BIC: {{ bankDetails.bic }}</b><br /><br />
|
||||
Bitte geben Sie als Verwendungszweck unbedingt die Rechnungsnummer an.
|
||||
</div>
|
||||
</div>
|
||||
<div class="preview-footer">
|
||||
<div style="color:grey;text-align: center; width: 100%;">
|
||||
<span>XINON GmbH | Fladnitz 150 | 8322 Studenzen</span><br>
|
||||
<span>Tel.: +43 3115 40800 | E-Mail: office@xinon.at</span><br>
|
||||
<span>UID: ATU68711968 | FN: 416556h | LG: Feldbach</span><br>
|
||||
<span>IBAN: {{ bankDetails.iban }} | BIC: {{ bankDetails.bic }}</span><br>
|
||||
</div>
|
||||
<div class="page-number">Seite 1 von 1</div>
|
||||
<div class="pdf-preview-container">
|
||||
<div v-if="pdfLoading" class="pdf-loading">
|
||||
<i class="fas fa-spinner fa-spin fa-3x"></i>
|
||||
<p>PDF wird generiert...</p>
|
||||
</div>
|
||||
<object v-else :data="pdfPreviewUrl" type="application/pdf" width="100%" height="100%">
|
||||
<p>PDF Vorschau kann nicht angezeigt werden. <a :href="pdfPreviewUrl" target="_blank">Hier klicken zum Öffnen</a></p>
|
||||
</object>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -230,22 +192,36 @@ Vue.component('manual-invoice-modal', {
|
||||
selectedCustomerObject: {},
|
||||
isLargeScreen: window.innerWidth >= 1920,
|
||||
showPreviewOnSmallScreen: false,
|
||||
pdfLoading: false,
|
||||
pdfPreviewUrl: '',
|
||||
previewDebounceTimer: null,
|
||||
invoiceData: {
|
||||
id: null,
|
||||
invoiceNumber: `RE-${new Date().getFullYear()}-`,
|
||||
invoiceDate: moment().unix(),
|
||||
dueDate: moment().add(14, 'days').unix(),
|
||||
status: 'draft',
|
||||
billingAddressId: null,
|
||||
customer: { company: '', name: '', street: '', zip: '', city: '', country: 'Österreich', uid: '' },
|
||||
invoice_number: `MRN${new Date().getFullYear()}-X000001`,
|
||||
invoice_date: moment().unix(),
|
||||
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: [],
|
||||
closingText: 'Wir danken für Ihren Auftrag und verbleiben mit freundlichen Grüßen,\nIhr Xinon Team',
|
||||
taxText: '',
|
||||
},
|
||||
bankDetails: {
|
||||
iban: 'ATXX XXXX XXXX XXXX XXXX',
|
||||
bic: 'XXXXXXXX'
|
||||
total: 0,
|
||||
total_gross: 0
|
||||
},
|
||||
billingTypeOptions: [
|
||||
{value: 'invoice', text: 'Rechnung'},
|
||||
{value: 'sepa', text: 'SEPA'}
|
||||
],
|
||||
positionsConfig: {
|
||||
fields: {
|
||||
product_name: { type: 'input', label: 'Bezeichnung' },
|
||||
@@ -289,10 +265,27 @@ Vue.component('manual-invoice-modal', {
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'invoiceData.billingAddressId': {
|
||||
'invoiceData': {
|
||||
handler() {
|
||||
this.debouncedPreviewUpdate();
|
||||
},
|
||||
deep: true
|
||||
},
|
||||
'invoiceData.billingaddress_id': {
|
||||
async handler(newId) {
|
||||
if (!newId) {
|
||||
this.invoiceData.customer = { company: '', name: '', street: '', zip: '', city: '', country: 'Österreich', uid: '' };
|
||||
this.invoiceData.company = '';
|
||||
this.invoiceData.firstname = '';
|
||||
this.invoiceData.lastname = '';
|
||||
this.invoiceData.street = '';
|
||||
this.invoiceData.zip = '';
|
||||
this.invoiceData.city = '';
|
||||
this.invoiceData.country = 'Österreich';
|
||||
this.invoiceData.uid = '';
|
||||
this.invoiceData.email = '';
|
||||
this.invoiceData.customer_number = 0;
|
||||
this.invoiceData.fibu_account_number = 0;
|
||||
this.invoiceData.owner_id = 0;
|
||||
this.selectedCustomerObject = {};
|
||||
return;
|
||||
}
|
||||
@@ -300,15 +293,18 @@ Vue.component('manual-invoice-modal', {
|
||||
if (response.data.status === 'OK' && response.data.result.address) {
|
||||
const addr = response.data.result.address;
|
||||
this.selectedCustomerObject = addr;
|
||||
this.invoiceData.customer = {
|
||||
company: addr.company,
|
||||
name: `${addr.firstname} ${addr.lastname}`,
|
||||
street: addr.street,
|
||||
zip: addr.zip,
|
||||
city: addr.city,
|
||||
country: 'Österreich',
|
||||
uid: addr.uid
|
||||
};
|
||||
this.invoiceData.company = addr.company || '';
|
||||
this.invoiceData.firstname = addr.firstname || '';
|
||||
this.invoiceData.lastname = addr.lastname || '';
|
||||
this.invoiceData.street = addr.street || '';
|
||||
this.invoiceData.zip = addr.zip || '';
|
||||
this.invoiceData.city = addr.city || '';
|
||||
this.invoiceData.country = 'Österreich';
|
||||
this.invoiceData.uid = addr.uid || '';
|
||||
this.invoiceData.email = addr.email || '';
|
||||
this.invoiceData.customer_number = addr.customer_number || 0;
|
||||
this.invoiceData.fibu_account_number = addr.fibu_account_number || 0;
|
||||
this.invoiceData.owner_id = newId;
|
||||
}
|
||||
},
|
||||
immediate: true
|
||||
@@ -316,16 +312,10 @@ Vue.component('manual-invoice-modal', {
|
||||
},
|
||||
created() {
|
||||
if (this.initialData) {
|
||||
// FIX: Merge initial data with default structure to ensure all keys, especially nested ones, exist.
|
||||
this.invoiceData = {
|
||||
...this.invoiceData, // Start with default structure
|
||||
...JSON.parse(JSON.stringify(this.initialData)) // Overwrite with passed data
|
||||
...this.invoiceData,
|
||||
...JSON.parse(JSON.stringify(this.initialData))
|
||||
};
|
||||
// Explicitly ensure nested objects exist if they weren't in initialData
|
||||
if (!this.invoiceData.customer) {
|
||||
this.invoiceData.customer = { company: '', name: '', street: '', zip: '', city: '', country: 'Österreich', uid: '' };
|
||||
}
|
||||
// Ensure positions is an array
|
||||
if (!Array.isArray(this.invoiceData.positions)) {
|
||||
try {
|
||||
const parsed = JSON.parse(this.invoiceData.positions);
|
||||
@@ -338,35 +328,115 @@ Vue.component('manual-invoice-modal', {
|
||||
},
|
||||
mounted() {
|
||||
window.addEventListener('resize', this.handleResize);
|
||||
window.addEventListener('keydown', this.handleGlobalKeydown);
|
||||
this.handleResize();
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.overlay) {
|
||||
this.$refs.overlay.focus();
|
||||
}
|
||||
this.updatePdfPreview();
|
||||
});
|
||||
},
|
||||
beforeDestroy() {
|
||||
window.removeEventListener('resize', this.handleResize);
|
||||
window.removeEventListener('keydown', this.handleGlobalKeydown);
|
||||
if (this.previewDebounceTimer) {
|
||||
clearTimeout(this.previewDebounceTimer);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
close() { this.$emit('close'); },
|
||||
handleResize() { this.isLargeScreen = window.innerWidth >= 1920; },
|
||||
togglePreviewVisibility() { if (!this.isLargeScreen) this.showPreviewOnSmallScreen = !this.showPreviewOnSmallScreen; },
|
||||
formatPrice(value) { return new Intl.NumberFormat('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(value || 0); },
|
||||
formatDate(timestamp) {
|
||||
if (!timestamp) return '';
|
||||
return moment.unix(timestamp).format('DD.MM.YYYY');
|
||||
},
|
||||
formatPeriod(start, end) {
|
||||
if (!start) return '';
|
||||
const startDate = moment(start);
|
||||
const endDate = end ? moment(end) : moment(start);
|
||||
if (!startDate.isValid()) return '';
|
||||
if (startDate.isSame(endDate, 'day')) return startDate.format('DD.MM.YYYY');
|
||||
if(startDate.isValid() && endDate.isValid()) {
|
||||
return `${startDate.format('DD.MM.YYYY')} - ${endDate.format('DD.MM.YYYY')}`;
|
||||
saveInvoice() {
|
||||
if (!this.invoiceData.billingaddress_id) {
|
||||
window.notify('error', 'Bitte wählen Sie einen Kunden aus.');
|
||||
return;
|
||||
}
|
||||
if (!this.invoiceData.positions || this.invoiceData.positions.length === 0) {
|
||||
window.notify('error', 'Bitte fügen Sie mindestens eine Position hinzu.');
|
||||
return;
|
||||
}
|
||||
this.$emit('save', this.invoiceData);
|
||||
},
|
||||
handleResize() { this.isLargeScreen = window.innerWidth >= 1920; },
|
||||
handleGlobalKeydown(event) {
|
||||
// Handle CTRL+Q to toggle preview on small screens
|
||||
if (event.ctrlKey && event.key === 'q') {
|
||||
event.preventDefault();
|
||||
this.togglePreviewVisibility();
|
||||
}
|
||||
},
|
||||
togglePreviewVisibility() { if (!this.isLargeScreen) this.showPreviewOnSmallScreen = !this.showPreviewOnSmallScreen; },
|
||||
debouncedPreviewUpdate() {
|
||||
if (this.previewDebounceTimer) {
|
||||
clearTimeout(this.previewDebounceTimer);
|
||||
}
|
||||
this.previewDebounceTimer = setTimeout(() => {
|
||||
this.updatePdfPreview();
|
||||
}, 2000);
|
||||
},
|
||||
async updatePdfPreview() {
|
||||
this.pdfLoading = true;
|
||||
|
||||
try {
|
||||
// Calculate position totals
|
||||
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;
|
||||
const price_total = amount * price;
|
||||
const price_gross = price_total * (1 + vatrate / 100);
|
||||
|
||||
return {
|
||||
...p,
|
||||
amount,
|
||||
price,
|
||||
vatrate,
|
||||
price_total,
|
||||
price_gross
|
||||
};
|
||||
});
|
||||
|
||||
const payload = {
|
||||
preview: true,
|
||||
invoice_number: this.invoiceData.invoice_number,
|
||||
invoice_date: this.invoiceData.invoice_date,
|
||||
customer_number: this.invoiceData.customer_number,
|
||||
fibu_account_number: this.invoiceData.fibu_account_number,
|
||||
company: this.invoiceData.company,
|
||||
firstname: this.invoiceData.firstname,
|
||||
lastname: this.invoiceData.lastname,
|
||||
street: this.invoiceData.street,
|
||||
zip: this.invoiceData.zip,
|
||||
city: this.invoiceData.city,
|
||||
country: this.invoiceData.country,
|
||||
email: this.invoiceData.email,
|
||||
uid: this.invoiceData.uid,
|
||||
tax_text: this.invoiceData.tax_text,
|
||||
billing_type: this.invoiceData.billing_type,
|
||||
total: this.totals.net,
|
||||
total_gross: this.totals.gross,
|
||||
positions: positions
|
||||
};
|
||||
|
||||
const response = await axios.post(
|
||||
`${window.TT_CONFIG.BASE_PATH}/ManualInvoice/createPDF`,
|
||||
payload,
|
||||
{ responseType: 'blob' }
|
||||
);
|
||||
|
||||
// Create a blob URL for the PDF
|
||||
const blob = new Blob([response.data], { type: 'application/pdf' });
|
||||
if (this.pdfPreviewUrl) {
|
||||
URL.revokeObjectURL(this.pdfPreviewUrl);
|
||||
}
|
||||
this.pdfPreviewUrl = URL.createObjectURL(blob) + '#view=FitH';
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error generating PDF preview:', error);
|
||||
window.notify('error', 'Fehler beim Generieren der PDF-Vorschau');
|
||||
} finally {
|
||||
this.pdfLoading = false;
|
||||
}
|
||||
return startDate.format('DD.MM.YYYY');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user