Merge branch 'Cpeprovisioning/improve' into 'master'

Add discounts, fields, and PDF/email support to manual invoices.

See merge request fronk/thetool!1929
This commit is contained in:
Luca Haid
2025-12-04 14:02:39 +00:00
13 changed files with 812 additions and 125 deletions

View File

@@ -65,10 +65,12 @@
<div>{{ addressLine_4 }}</div>
<div>{{ addressLine_5 }}</div>
</td>
<td style="float: right">
<table class="info-table">
<td style="vertical-align: top; text-align: right;">
<table style="display: inline-table; vertical-align: top;">
<tr>
<td></td>
<td style="vertical-align: top; padding-right: 10px;">
<img alt="QR-Code" src="{{ qrCodeSrc }}" style="display: block; height: 100%; max-height: 3.5cm; width: auto;">
</td>
<td>
<table class="invoice-details">
<tr>
@@ -87,16 +89,14 @@
<td>Belegdatum:</td>
<td>{{ invoiceDate }}</td>
</tr>
{{ leistungszeitraumHtml }}
{{ externeReferenzHtml }}
{{ vatHtml }}
</table>
</td>
</tr>
</table>
</td>
<td style="float: right; vertical-align: top; margin-top: 0; padding-top: 0">
<img alt="QR-Code" src="{{ qrCodeSrc }}" style="text-align:right;height: 3.5cm;">
</td>
</tr>
</table>

View File

@@ -8,6 +8,43 @@ $net_total = $invoice->total;
$gross_total = $invoice->total_gross;
$is_credit = $net_total < 0;
// Check if any position has a discount to conditionally show the discount column
$hasDiscount = false;
foreach($invoice->positions as $p) {
if (($p->discount ?? 0) > 0) {
$hasDiscount = true;
break;
}
}
$gesamtrabatt = $invoice->gesamtrabatt ?? 0;
$subtotal = 0;
foreach($invoice->positions as $p) {
$subtotal += $p->price_total ?? 0;
}
// Group positions by position_group
$groupedPositions = [];
$hasGroups = false;
foreach($invoice->positions as $p) {
if (!empty($p->position_group)) {
$hasGroups = true;
}
}
// If no positions have groups, put all in default (no group header will be shown)
if (!$hasGroups) {
$groupedPositions['_default'] = $invoice->positions;
} else {
foreach($invoice->positions as $p) {
$group = $p->position_group ?? 'Sonstige';
if (!isset($groupedPositions[$group])) {
$groupedPositions[$group] = [];
}
$groupedPositions[$group][] = $p;
}
}
$this->setReturnValue(['filename' => $invoice->invoice_number . ".pdf"]);
?>
@@ -48,12 +85,18 @@ $this->setReturnValue(['filename' => $invoice->invoice_number . ".pdf"]);
height: 28px;
}
#invoiceTable tr *:nth-child(5),
#invoiceTable tr *:nth-child(2),
#invoiceTable tr *:nth-child(4),
#invoiceTable tr *:nth-child(3) {
#invoiceTable tr *:nth-child(5),
#invoiceTable tr *:nth-child(6),
#invoiceTable tr *:nth-child(7) {
text-align: right;
}
#invoiceTable tr *:nth-child(3) {
text-align: center;
}
#invoiceTable tr *:not(:first-child) {
padding: 4px 0;
}
@@ -72,7 +115,7 @@ $this->setReturnValue(['filename' => $invoice->invoice_number . ".pdf"]);
}
#invoiceTable tr td:first-child {
max-width: 200pt;
max-width: 280pt;
}
</style>
@@ -84,88 +127,77 @@ $this->setReturnValue(['filename' => $invoice->invoice_number . ".pdf"]);
<h2 style="text-align: center;color: #005384">Ihre Xinon <?=($is_credit) ? "Gutschrift" : "Rechnung"?> vom <?=date("d.m.Y",$invoice->invoice_date)?></h2>
<?php if($invoice->einleitender_text ?? ''): ?>
<p style="margin-top: 10pt; margin-bottom: 20pt; text-align: center; font-weight: bold;"><?=nl2br(htmlspecialchars($invoice->einleitender_text))?></p>
<?php endif; ?>
<table style="border-collapse: collapse; width: 100%;" id="invoiceTable">
<tr style="font-weight: bold; border-bottom: 1px solid black;" class="uneven">
<th style="text-align: center">Leistung / Produkt</th>
<th style="text-align: center">Zeitraum</th>
<th style="text-align: right">Preis</th>
<th style="text-align: center">Menge</th>
<?php if($hasDiscount): ?><th style="text-align: right">Rabatt %</th><?php endif; ?>
<th style="text-align: right">Netto €</th>
<th style="text-align: right">Ust. %</th>
<th style="text-align: right; padding-right: 4pt">Brutto €</th>
</tr>
<?php
$i = 0;
foreach($invoice->positions as $p):
$timerange_month_only = method_exists($p, 'getOption') ? $p->getOption('timerange_month_only') : (isset($p->options) ? (json_decode($p->options, true)['timerange_month_only'] ?? false) : false);
// Handle dates safely
$start_date = null;
$end_date = null;
if (!empty($p->start_date)) {
try {
$start_date = new DateTime($p->start_date);
} catch (Exception $e) {
$start_date = null;
}
}
if (!empty($p->end_date)) {
try {
$end_date = new DateTime($p->end_date);
} catch (Exception $e) {
$end_date = $start_date;
}
} else {
$end_date = $start_date;
}
foreach($groupedPositions as $groupName => $positions):
?>
<!-- Group Header (only show if not default) -->
<?php if ($groupName !== '_default'): ?>
<tr style="background-color: #d9d9d9; font-weight: bold;">
<td colspan="<?=$hasDiscount ? '7' : '6'?>" style="padding: 6px 4pt; border-top: 1px solid black; text-align: left;">
<?=htmlspecialchars($groupName)?>
</td>
</tr>
<?php endif; ?>
<?php
foreach($positions as $p):
$amount = (float) number_format($p->amount ?? 0, 3, ",", ".");
$unit = htmlspecialchars($p->unit ?? 'Stk.');
$price = number_format($p->price ?? 0, 2, ",",".");
$discount = $p->discount ?? 0;
$price_total = number_format($p->price_total ?? 0, 2, ",",".");
$price_gross = number_format($p->price_gross ?? 0, 2, ",",".");
$vatrate = number_format($p->vatrate ?? 0, 0, ",",".");
?>
<tr class="position <?=($i%2 == 0) ? "even" : "uneven" ?>">
<td>
<td style="padding-left: 4pt; vertical-align: top;">
<?=htmlspecialchars($p->product_name ?? '')?>
<?php if(isset($p->product_info) && $p->product_info): ?>
<div style="padding-left: 12pt; font-size: 10px; color: #666;"><?=htmlspecialchars($p->product_info)?></div>
<?php endif; ?>
<?php if(isset($p->matchcode) && $p->matchcode): ?>
<div style="padding-left: 12pt"><?=htmlspecialchars($p->matchcode)?></div>
<div style="padding-left: 12pt; font-size: 10px; color: #666;"><?=htmlspecialchars($p->matchcode)?></div>
<?php endif; ?>
</td>
<td style="text-align: center;">
<?php if($start_date && $end_date): ?>
<?php if($timerange_month_only): ?>
<?=$start_date->format("m.Y")?>
<?php elseif(isset($p->billing_period) && $p->billing_period > 1): ?>
<?=$start_date->format("m.Y")?> - <?=$end_date->format("m.Y") ?>
<?php else: ?>
<?php if($start_date->format("d.m.Y") == $end_date->format("d.m.Y")): ?>
<?=$start_date->format("d.m.Y")?>
<?php else: ?>
<?=$start_date->format("d.m.Y")?> - <?=$end_date->format("d.m.Y") ?>
<?php endif; ?>
<?php endif; ?>
<?php elseif($start_date): ?>
<?=$start_date->format("d.m.Y")?>
<?php else: ?>
-
<?php endif; ?>
</td>
<td><?=$price?> €</td>
<td style="text-align: center"><?=$amount?></td>
<td><?=$price_total?> €</td>
<td style="text-align: right;"><?=$vatrate?>%</td>
<td style="padding-right: 4pt;"><?=$price_gross?> €</td>
<td style="text-align: right; padding: 4px 0;"><?=$price?> €</td>
<td style="text-align: center; padding: 4px 0;"><?=$amount?> <?=$unit?></td>
<?php if($hasDiscount): ?><td style="text-align: right; padding: 4px 0;"><?=number_format($discount, 2, ",", ".")?>%</td><?php endif; ?>
<td style="text-align: right; padding: 4px 0;"><?=$price_total?> €</td>
<td style="text-align: right; padding: 4px 0;"><?=$vatrate?>%</td>
<td style="text-align: right; padding: 4px 0; padding-right: 4pt;"><?=$price_gross?> €</td>
</tr>
<?php
$i++;
endforeach;
endforeach;
?>
<tr style="font-weight: bold; background-color: #ebebeb; border-bottom: 1px solid black;border-top: 1px solid black">
<td colspan="5">Gesamt Netto:</td>
<?php if($gesamtrabatt > 0): ?>
<tr style="background-color: #ebebeb; border-top: 2px solid black;">
<td colspan="<?=$hasDiscount ? '5' : '4'?>" style="padding: 4px 0;">Zwischensumme:</td>
<td colspan="2" style="text-align: right; padding-right: 4pt;"><?=number_format($subtotal, 2, ",","."). " €"?></td>
</tr>
<tr style="background-color: #ebebeb; border-bottom: 1px solid #ccc;">
<td colspan="<?=$hasDiscount ? '5' : '4'?>" style="padding: 4px 0;">Gesamtrabatt <?=number_format($gesamtrabatt, 2, ",", ".")?>%:</td>
<td colspan="2" style="text-align: right; padding-right: 4pt; color: #d32f2f;">-<?=number_format($subtotal * ($gesamtrabatt / 100), 2, ",","."). " €"?></td>
</tr>
<?php endif; ?>
<tr style="font-weight: bold; background-color: #ebebeb; border-bottom: 1px solid black;<?=($gesamtrabatt > 0) ? '' : 'border-top: 2px solid black;'?>">
<td colspan="<?=$hasDiscount ? '5' : '4'?>" style="padding: 4px 0;">Gesamt Netto:</td>
<td colspan="2" style="text-align: right; padding-right: 4pt;"><?=number_format($net_total, 2, ",","."). " €"?></td>
</tr>
@@ -173,7 +205,7 @@ $this->setReturnValue(['filename' => $invoice->invoice_number . ".pdf"]);
<?php if($rate > 0): ?>
<tr style="font-size: 11px;border-bottom: 1px solid black;">
<td colspan="5">USt. <?=number_format($rate, 0, ",", ".")?>%:</td>
<td colspan="<?=$hasDiscount ? '5' : '4'?>" style="padding: 4px 0;">USt. <?=number_format($rate, 0, ",", ".")?>%:</td>
<td colspan="2" style="text-align: right; padding-right: 4pt;"><?=number_format($vat_total, 2, ",","."). " €"?></td>
</tr>
<?php endif; ?>
@@ -182,7 +214,7 @@ $this->setReturnValue(['filename' => $invoice->invoice_number . ".pdf"]);
<!-- double underline border on bottom -->
<tr style="font-weight: bold; border-bottom: 3px double black; background-color: #ebebeb;">
<td colspan="5">Gesamt Brutto:</td>
<td colspan="<?=$hasDiscount ? '5' : '4'?>" style="padding: 4px 0;">Gesamt Brutto:</td>
<td colspan="2" style="text-align: right; padding-right: 4pt;"><?=number_format($gross_total, 2, ",","."). " €"?></td>
</tr>
</table>
@@ -193,14 +225,31 @@ $this->setReturnValue(['filename' => $invoice->invoice_number . ".pdf"]);
<p style="font-weight: bold;"><?=$invoice->tax_text?></p>
<?php endif; ?>
<?php if($is_credit): ?>
<p style="color: #FF0000; font-weight: bold">Gutschrift! Bitte nicht überweisen.</p>
<p style="color: #FF0000; font-weight: bold; text-align: center;">Gutschrift! Bitte nicht überweisen.</p>
<?php elseif($invoice->billing_type == "sepa"): ?>
<p style="color: #FF0000; font-weight: bold">BITTE NICHT EINZAHLEN, DER BETRAG WIRD AUTOMATISCH VON IHREM KONTO ABGEBUCHT !</p>
<p style="color: #FF0000; font-weight: bold; text-align: center;">BITTE NICHT EINZAHLEN DER BETRAG WIRD AUTOMATISCH VON IHREM KONTO ABGEBUCHT!</p>
<?php else: ?>
Bitte <b>überweisen</b> Sie den Rechnungsbetrag bis zum &nbsp;<b><?=(new DateTime("@".$invoice->invoice_date))->modify("+14 days")->format("d.m.Y")?></b> auf folgendes Konto:<br />
<b style="padding-left: 4pt;">IBAN: <?=$bank_iban?></b><br />
<b style="padding-left: 4pt;">BIC: <?=$bank_bic?></b><br /><br />
Bitte geben Sie als Verwendungszweck unbedingt die Rechnungsnummer an, nur so können wir Ihre Zahlung eindeutig zuordnen
<div style="border-top: 1px solid #ccc; padding-top: 10pt; margin-top: 10pt;">
<p style="margin-bottom: 8pt;">
<strong>Zahlungsinformationen:</strong>
</p>
<p style="margin-bottom: 4pt;">
Bitte überweisen Sie den Rechnungsbetrag bis zum <strong><?=(new DateTime("@".$invoice->invoice_date))->modify("+14 days")->format("d.m.Y")?></strong> auf folgendes Konto:
</p>
<table style="margin-left: 20pt; margin-bottom: 12pt;">
<tr><td style="width: 100pt;"><strong>IBAN:</strong></td><td><?=$bank_iban?></td></tr>
<tr><td><strong>BIC:</strong></td><td><?=$bank_bic?></td></tr>
<tr><td><strong>Bank:</strong></td><td><?=$bank_bank?></td></tr>
</table>
<div style="background-color: #f5f5f5; padding: 10pt; border-left: 3px solid #005384; margin-top: 12pt;">
<p style="margin: 0; margin-bottom: 4pt; font-size: 14px;">
<strong>Verwendungszweck: <?=$invoice->invoice_number ?? "VORSCHAU"?></strong>
</p>
<p style="margin: 0; font-size: 10px; color: #666;">
Wichtig: Bitte geben Sie den oben angeführten Verwendungszweck bei der Überweisung an, damit wir Ihre Zahlung eindeutig zuordnen können.
</p>
</div>
</div>
<?php endif; ?>
</div>

View File

@@ -1,5 +1,8 @@
<?php
use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception;
class ManualInvoiceController extends TTCrud
{
protected string $headerTitle = 'Manuelle Rechnungen';
@@ -19,9 +22,9 @@ class ManualInvoiceController extends TTCrud
['key' => 'total', 'text' => 'Netto', 'table' => ['sortable' => true, 'formatter' => 'formatPrice']],
['key' => 'total_gross', 'text' => 'Brutto', 'table' => ['sortable' => true, 'formatter' => 'formatPrice']],
['key' => 'status', 'text' => 'Status', 'table' => ['filter' => 'select', 'filterOptions' => [
['value' => 'draft', 'text' => 'Entwurf'],
['value' => 'finalized', 'text' => 'Finalisiert'],
['value' => 'exported', 'text' => 'Exportiert'],
['value' => 'erstellt', 'text' => 'Erstellt'],
['value' => 'gesendet', 'text' => 'Gesendet'],
['value' => 'exportiert', 'text' => 'Exportiert'],
]]],
['key' => 'billing_type', 'text' => 'Zahlungsart', 'table' => ['filter' => 'select', 'filterOptions' => [
['value' => 'invoice', 'text' => 'Rechnung'],
@@ -31,7 +34,9 @@ class ManualInvoiceController extends TTCrud
];
protected array $additionalActions = [
['key' => 'createGutschrift', 'title' => 'Gutschrift erstellen', 'class' => 'fas fa-file-invoice text-warning']
['key' => 'createGutschrift', 'title' => 'Gutschrift erstellen', 'class' => 'fas fa-file-invoice text-warning'],
['key' => 'pdfPreview', 'title' => 'PDF Vorschau', 'class' => 'fas fa-file-pdf text-danger'],
['key' => 'sendInvoice', 'title' => 'Rechnung aussenden', 'class' => 'fas fa-paper-plane text-success']
];
protected function createPDFAction($returnFilename = false) {
@@ -46,9 +51,23 @@ class ManualInvoiceController extends TTCrud
'company' => '', 'firstname' => '', 'lastname' => '',
'street' => '', 'zip' => '', 'city' => '', 'country' => 'Österreich',
'email' => '', 'uid' => '', 'tax_text' => '', 'billing_type' => 'invoice',
'leistungszeitraum' => '', 'einleitender_text' => '', 'externe_referenz' => '', 'gesamtrabatt' => 0,
'total' => 0, 'total_gross' => 0
], $post);
$positions = array_map(fn($p) => (object)$p, $post['positions'] ?? []);
// Convert invoice_date from string to timestamp if needed
if (isset($invoice->invoice_date) && is_string($invoice->invoice_date)) {
$invoice->invoice_date = strtotime($invoice->invoice_date);
}
$positions = array_map(function($p) {
$obj = (object)$p;
// Map _group to position_group for preview
if (isset($p['_group'])) {
$obj->position_group = $p['_group'];
}
return $obj;
}, $post['positions'] ?? []);
} else {
$id = $this->request->id ?? $post['id'] ?? null;
if (!$id || !($invoice = ManualInvoiceModel::get($id))) {
@@ -86,6 +105,8 @@ class ManualInvoiceController extends TTCrud
"{{ billingAccount }}" => $invoice->fibu_account_number ?? '',
"{{ invoiceNumber }}" => $invoice->invoice_number ?? "VORSCHAU",
"{{ invoiceDate }}" => date("d.m.Y", $invoice->invoice_date ?? time()),
"{{ leistungszeitraumHtml }}" => ($invoice->leistungszeitraum ?? '') ? "<tr><td>Leistungszeitraum:</td><td>" . htmlspecialchars($invoice->leistungszeitraum) . "</td></tr>" : "",
"{{ externeReferenzHtml }}" => ($invoice->externe_referenz ?? '') ? "<tr><td>Externe Referenz:</td><td>" . htmlspecialchars($invoice->externe_referenz) . "</td></tr>" : "",
"{{ vatHtml }}" => ($invoice->uid ?? '') ? "<tr><td>Ihre UID:</td><td>" . $invoice->uid . "</td></tr>" : "",
"{{ qrCodeSrc }}" => $this->generateSepaQRCode($invoice->invoice_number ?? "VORSCHAU", round($invoice->total_gross ?? 0, 2))
];
@@ -134,6 +155,186 @@ class ManualInvoiceController extends TTCrud
exit;
}
protected function pdfPreviewAction() {
$post = json_decode(file_get_contents('php://input'), true);
$id = $post['id'] ?? null;
if (!$id || !($invoice = ManualInvoiceModel::get($id))) {
self::returnJson(['success' => false, 'message' => 'Rechnung nicht gefunden']);
return;
}
// Log PDF preview in journal
$me = new User();
$me->loadMe();
ManualInvoiceJournalModel::create([
'manualinvoiceId' => $id,
'text' => 'PDF Vorschau geöffnet',
'createBy' => $me->id,
'create' => time()
]);
// Return URL to open in new tab
$url = "?action=ManualInvoice_createPDF&id=" . $id;
self::returnJson(['success' => true, 'url' => $url]);
}
protected function getInvoiceEmailAction() {
$post = json_decode(file_get_contents('php://input'), true);
$id = $post['id'] ?? null;
if (!$id || !($invoice = ManualInvoiceModel::get($id))) {
self::returnJson(['success' => false, 'message' => 'Rechnung nicht gefunden']);
return;
}
self::returnJson([
'success' => true,
'invoice' => [
'id' => $invoice->id,
'invoice_number' => $invoice->invoice_number,
'email' => $invoice->email,
'customerName' => trim(($invoice->company ?: '') . ' ' . $invoice->firstname . ' ' . $invoice->lastname)
]
]);
}
protected function sendInvoiceEmailAction() {
// Enable error reporting for debugging
error_reporting(E_ALL);
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
$post = json_decode(file_get_contents('php://input'), true);
$id = $post['id'] ?? null;
$recipientEmail = $post['email'] ?? null;
$subject = $post['subject'] ?? 'Ihre Rechnung von XINON GmbH';
$bodyText = $post['body'] ?? 'Sehr geehrte Damen und Herren,\n\nanbei erhalten Sie Ihre Rechnung.\n\nMit freundlichen Grüßen\nIhr Xinon Team';
if (!$id || !$recipientEmail) {
self::returnJson(['success' => false, 'message' => 'ID oder E-Mail-Adresse fehlt']);
return;
}
$invoice = ManualInvoiceModel::get($id);
if (!$invoice) {
self::returnJson(['success' => false, 'message' => 'Rechnung nicht gefunden']);
return;
}
// Generate PDF
$pdf_filename = $this->createPDFAction(true);
if (!$pdf_filename || !file_exists($pdf_filename)) {
self::returnJson(['success' => false, 'message' => 'PDF konnte nicht erstellt werden']);
return;
}
$pdfContent = file_get_contents($pdf_filename);
// --- HTML Email Generation ---
$logoToolPath = BASEDIR . '/public/assets/images/the-tool-logo.png';
$logoXinonPath = BASEDIR . '/public/assets/images/xinon-full.png';
$logoToolExists = file_exists($logoToolPath);
$logoXinonExists = file_exists($logoXinonPath);
// Construct HTML Body
$html = '<!DOCTYPE html><html lang="de"><head><meta charset="UTF-8"><title>Rechnung</title><style>body { font-family: Arial, sans-serif; color: #333; }</style></head><body style="margin:0;padding:20px;background-color:#f3f4f6;">';
$html .= '<div style="background-color:#fff;padding:20px;border-radius:8px;max-width:600px;margin:0 auto;box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);">';
// Logos
$html .= '<div style="text-align:center;margin-bottom:20px;border-bottom: 1px solid #e5e7eb;padding-bottom: 15px;">';
if ($logoToolExists) $html .= '<img src="cid:logo_thetool" alt="The Tool" style="height:40px;margin-right:15px;vertical-align:middle;">';
if ($logoXinonExists) $html .= '<img src="cid:logo_xinon" alt="Xinon" style="height:40px;vertical-align:middle;">';
$html .= '</div>';
$html .= '<h2 style="color:#00558c;text-align:center;font-size:20px;margin-bottom:20px;">' . htmlspecialchars($subject) . '</h2>';
$html .= '<div style="font-size:14px;line-height:1.6;color:#333;">';
$html .= nl2br(htmlspecialchars($bodyText));
$html .= '</div>';
$html .= '<br><div style="border-top:1px solid #eee;padding-top:20px;font-size:12px;color:#999;text-align:center;">';
$html .= 'XINON GmbH | <a href="https://www.xinon.at" style="color:#00558c;text-decoration:none;">www.xinon.at</a>';
$html .= '</div></div></body></html>';
$mail = new PHPMailer(true);
try {
// Server settings
$mail->isSMTP();
$mail->Host = TT_PIPEWORK_SMTP_HOST;
$mail->SMTPAuth = true;
$mail->Username = TT_PIPEWORK_SMTP_USER;
$mail->Password = TT_PIPEWORK_SMTP_PASS;
$mail->CharSet = PHPMailer::CHARSET_UTF8;
$mail->Encoding = 'base64';
$mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS;
$mail->Port = 587;
// Logos
if ($logoToolExists) $mail->addEmbeddedImage($logoToolPath, 'logo_thetool');
if ($logoXinonExists) $mail->addEmbeddedImage($logoXinonPath, 'logo_xinon');
$mail->addReplyTo('backoffice@xinon.at', 'XINON Backoffice');
$mail->setFrom('thetool@xinon.at', 'XINON TheTool');
$customerName = trim(($invoice->company ?: '') . ' ' . $invoice->firstname . ' ' . $invoice->lastname);
$mail->addAddress($recipientEmail, $customerName);
$mail->Subject = $subject;
$mail->isHTML(true);
$mail->Body = $html;
$mail->AltBody = strip_tags($bodyText);
$mail->addStringAttachment($pdfContent, $invoice->invoice_number . '_Rechnung.pdf', 'base64', 'application/pdf');
$mail->send();
// Update invoice status
$invoice->status = 'gesendet';
$invoice->save();
// Add Journal Entry
$me = new User();
$me->loadMe();
ManualInvoiceJournalModel::create([
'manualinvoiceId' => $id,
'text' => "Rechnung per E-Mail an $recipientEmail gesendet.",
'statusChange' => 'gesendet',
'createBy' => $me->id,
'create' => time()
]);
self::returnJson(['success' => true, 'message' => 'E-Mail erfolgreich versendet an ' . $recipientEmail]);
} catch (Exception $e) {
self::returnJson(['success' => false, 'message' => 'E-Mail konnte nicht gesendet werden. Fehler: ' . $mail->ErrorInfo]);
}
}
protected function downloadInvoiceAction() {
$post = json_decode(file_get_contents('php://input'), true);
$id = $post['id'] ?? null;
if (!$id || !($invoice = ManualInvoiceModel::get($id))) {
self::returnJson(['success' => false, 'message' => 'Rechnung nicht gefunden']);
return;
}
$me = new User();
$me->loadMe();
// Log download in journal
ManualInvoiceJournalModel::create([
'manualinvoiceId' => $id,
'text' => 'Rechnung heruntergeladen',
'createBy' => $me->id,
'create' => time()
]);
$downloadUrl = "?action=ManualInvoice_downloadInvoicePdf&id=" . $id;
self::returnJson([
'success' => true,
'url' => $downloadUrl
]);
}
protected function beforeCreate(&$data): bool {
if (isset($data['positions']) && is_array($data['positions'])) {
$this->tempPositions = $data['positions'];
@@ -143,12 +344,18 @@ class ManualInvoiceController extends TTCrud
$me = new User();
$me->loadMe();
// Convert invoice_date from string to timestamp if needed
if (isset($data['invoice_date']) && is_string($data['invoice_date'])) {
$data['invoice_date'] = strtotime($data['invoice_date']);
}
$data = array_merge([
'invoice_number' => ManualInvoiceModel::getNextInvoiceNumber(),
'invoice_date' => time(),
'status' => 'draft',
'invoice_date' => $data['invoice_date'] ?? time(),
'status' => 'erstellt',
'fibu_payment_skonto' => 0,
'fibu_payment_skonto_rate' => 0,
'gesamtrabatt' => 0,
'total' => 0,
'total_gross' => 0,
'create_by' => $me->id,
@@ -163,6 +370,17 @@ class ManualInvoiceController extends TTCrud
protected function afterCreate($data) {
$this->savePositions($data['id']);
$this->recalculateTotals($data['id']);
// Log creation in journal
$me = new User();
$me->loadMe();
ManualInvoiceJournalModel::create([
'manualinvoiceId' => $data['id'],
'text' => 'Rechnung erstellt',
'statusChange' => 'erstellt',
'createBy' => $me->id,
'create' => time()
]);
}
protected function beforeUpdate(&$data): bool {
@@ -171,11 +389,16 @@ class ManualInvoiceController extends TTCrud
unset($data['positions']);
}
if (isset($data['id']) && ($invoice = ManualInvoiceModel::get($data['id'])) && $invoice->status === 'exported') {
if (isset($data['id']) && ($invoice = ManualInvoiceModel::get($data['id'])) && $invoice->status === 'exportiert') {
$this->infoMessages['update'] = 'Rechnung wurde bereits exportiert und kann nicht mehr bearbeitet werden';
return false;
}
// Convert invoice_date from string to timestamp if needed
if (isset($data['invoice_date']) && is_string($data['invoice_date'])) {
$data['invoice_date'] = strtotime($data['invoice_date']);
}
$me = new User();
$me->loadMe();
$data['edit_by'] = $me->id;
@@ -190,6 +413,16 @@ class ManualInvoiceController extends TTCrud
$this->savePositions($data['id']);
$this->recalculateTotals($data['id']);
// Log update in journal
$me = new User();
$me->loadMe();
ManualInvoiceJournalModel::create([
'manualinvoiceId' => $data['id'],
'text' => 'Rechnung aktualisiert',
'createBy' => $me->id,
'create' => time()
]);
}
private function savePositions($invoiceId) {
@@ -199,9 +432,18 @@ class ManualInvoiceController extends TTCrud
$me->loadMe();
foreach ($this->tempPositions as $position) {
// Skip empty positions
if (empty($position['product_name']) || ($position['amount'] ?? 0) == 0) continue;
// Map _group to position_group
$groupName = $position['_group'] ?? null;
unset($position['_group']);
ManualInvoicepositionModel::create(array_merge([
'manualinvoice_id' => $invoiceId,
'start_date' => date('Y-m-d'),
'position_group' => $groupName,
'unit' => 'Stk.',
'discount' => 0,
'create_by' => $me->id,
'edit_by' => $me->id,
'create' => time(),
@@ -215,8 +457,23 @@ class ManualInvoiceController extends TTCrud
if (!($invoice = ManualInvoiceModel::get($invoiceId))) return;
$positions = ManualInvoicepositionModel::search(['manualinvoice_id' => $invoiceId]);
$invoice->total = array_sum(array_column($positions, 'price_total'));
$invoice->total_gross = array_sum(array_column($positions, 'price_gross'));
$subtotal = array_sum(array_column($positions, 'price_total'));
// Apply gesamtrabatt (total discount) if exists
$gesamtrabatt = $invoice->gesamtrabatt ?? 0;
$discountAmount = $subtotal * ($gesamtrabatt / 100);
$netTotal = $subtotal - $discountAmount;
// Calculate gross total with VAT applied after discount
$grossTotal = 0;
foreach ($positions as $pos) {
$positionNet = $pos->price_total;
$positionAfterDiscount = $positionNet * (1 - $gesamtrabatt / 100);
$grossTotal += $positionAfterDiscount * (1 + $pos->vatrate / 100);
}
$invoice->total = $netTotal;
$invoice->total_gross = $grossTotal;
$invoice->save();
}
@@ -272,23 +529,23 @@ class ManualInvoiceController extends TTCrud
return [
'id' => $pos->id,
'manualinvoice_id' => $pos->manualinvoice_id,
'_group' => $pos->position_group ?? '',
'billing_id' => $pos->billing_id,
'contract_id' => $pos->contract_id,
'start_date' => $pos->start_date,
'end_date' => $pos->end_date,
'matchcode' => $pos->matchcode,
'product_id' => $pos->product_id,
'product_name' => $pos->product_name,
'product_info' => $pos->product_info,
'amount' => $pos->amount,
'unit' => $pos->unit ?? 'Stk.',
'price' => $pos->price,
'discount' => $pos->discount ?? 0,
'price_total' => $pos->price_total,
'price_gross' => $pos->price_gross,
'vatrate' => $pos->vatrate,
'fibu_cost_account' => $pos->fibu_cost_account,
'fibu_cost_account_legacy' => $pos->fibu_cost_account_legacy,
'fibu_taxcode' => $pos->fibu_taxcode,
'billing_period' => $pos->billing_period,
'options' => $pos->options
];
}
@@ -340,6 +597,7 @@ class ManualInvoiceController extends TTCrud
'original_amount' => $pos->amount,
'credited_amount' => $creditedAmounts[$key] ?? 0,
'available_amount' => $availableAmount,
'unit' => $pos->unit ?? 'Stk.',
'price' => $pos->price,
'vatrate' => $pos->vatrate,
'product_id' => $pos->product_id,
@@ -376,6 +634,10 @@ class ManualInvoiceController extends TTCrud
$invoiceData = [
'invoice_number' => ManualInvoiceModel::getNextInvoiceNumber(),
'invoice_date' => time(),
'leistungszeitraum' => $originalInvoice->leistungszeitraum ?? null,
'einleitender_text' => 'Gutschrift zur Rechnung ' . $originalInvoice->invoice_number,
'externe_referenz' => $originalInvoice->externe_referenz ?? null,
'gesamtrabatt' => 0,
'owner_id' => $originalInvoice->owner_id,
'billingaddress_id' => $originalInvoice->billingaddress_id,
'customer_number' => $originalInvoice->customer_number,
@@ -410,7 +672,7 @@ class ManualInvoiceController extends TTCrud
'total_gross' => 0,
'vatgroup_id' => $originalInvoice->vatgroup_id,
'credit_for_invoice_id' => $originalInvoiceId,
'status' => 'finalized',
'status' => 'erstellt',
'create' => time(),
'edit' => time(),
'create_by' => $me->id,
@@ -425,11 +687,14 @@ class ManualInvoiceController extends TTCrud
$priceTotal = (-abs($pos['amount'])) * $pos['price'];
ManualInvoicepositionModel::create([
'manualinvoice_id' => $creditInvoiceId,
'position_group' => null,
'product_id' => $pos['product_id'],
'product_name' => $pos['product_name'],
'product_info' => $pos['product_info'] ?? '',
'amount' => -abs($pos['amount']),
'unit' => $pos['unit'] ?? 'Stk.',
'price' => $pos['price'],
'discount' => 0,
'vatrate' => $pos['vatrate'],
'price_total' => $priceTotal,
'price_gross' => $priceTotal * (1 + $pos['vatrate'] / 100),
@@ -437,8 +702,7 @@ class ManualInvoiceController extends TTCrud
'fibu_cost_account' => $pos['fibu_cost_account'] ?? null,
'fibu_taxcode' => $pos['fibu_taxcode'] ?? null,
'contract_id' => 0,
'start_date' => date('Y-m-d'),
'billing_period' => 0,
'billing_id' => null,
'create_by' => $me->id,
'edit_by' => $me->id,
'create' => time(),

View File

@@ -4,6 +4,10 @@ class ManualInvoiceModel extends TTCrudBaseModel {
public int $id;
public ?string $invoice_number;
public int $invoice_date;
public ?string $leistungszeitraum;
public ?string $einleitender_text;
public ?string $externe_referenz;
public float $gesamtrabatt;
public int $owner_id;
public int $billingaddress_id;
public int $customer_number;
@@ -51,13 +55,13 @@ class ManualInvoiceModel extends TTCrudBaseModel {
$last = $invoices[0]->invoice_number ?? null;
$year = date("Y");
if ($last && preg_match('/^MRN(\d+)-X(\d+)$/', $last, $m)) {
if ($last && preg_match('/^RN(\d+)-C(\d+)$/', $last, $m)) {
$num = ($m[1] == $year) ? $m[2] + 1 : 1;
} else {
$num = 1;
}
return sprintf("MRN%s-X%06d", $year, $num);
return sprintf("RN%s-C%06d", $year, $num);
}
public function getProperty($name) {

View File

@@ -0,0 +1,12 @@
<?php
class ManualInvoiceJournalModel extends TTCrudBaseModel {
public int $id;
public int $manualinvoiceId;
public ?string $text;
public ?string $data;
public ?string $statusChange;
public ?string $fileIds;
public int $create;
public int $createBy;
}

View File

@@ -3,23 +3,23 @@
class ManualInvoicepositionModel extends TTCrudBaseModel {
public int $id;
public ?int $manualinvoice_id;
public ?string $position_group;
public ?int $billing_id;
public int $contract_id;
public string $start_date;
public ?string $end_date;
public ?string $matchcode;
public int $product_id;
public string $product_name;
public ?string $product_info;
public float $amount;
public string $unit;
public float $price;
public float $discount;
public float $price_total;
public float $price_gross;
public float $vatrate;
public ?int $fibu_cost_account;
public ?int $fibu_cost_account_legacy;
public ?int $fibu_taxcode;
public int $billing_period;
public ?string $options;
public int $create_by;
public int $edit_by;

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class AddManualinvoiceAdditionalFields extends AbstractMigration
{
public function up(): void
{
if($this->getEnvironment() == "thetool") {
$table = $this->table("ManualInvoice");
$table->addColumn("leistungszeitraum", "string", ["null" => true, "default" => null, "length" => 255, "after" => "invoice_date"]);
$table->addColumn("einleitender_text", "text", ["null" => true, "default" => null, "after" => "leistungszeitraum"]);
$table->addColumn("externe_referenz", "string", ["null" => true, "default" => null, "length" => 255, "after" => "einleitender_text"]);
$table->addColumn("gesamtrabatt", "decimal", ["null" => false, "default" => 0, "precision" => 6, "scale" => 2, "after" => "externe_referenz"]);
$table->save();
$positionTable = $this->table("ManualInvoiceposition");
$positionTable->addColumn("discount", "decimal", ["null" => false, "default" => 0, "precision" => 6, "scale" => 2, "after" => "price"]);
$positionTable->save();
}
}
public function down(): void
{
if($this->getEnvironment() == "thetool") {
$this->table("ManualInvoiceposition")->removeColumn("discount")->save();
$this->table("ManualInvoice")
->removeColumn("leistungszeitraum")
->removeColumn("einleitender_text")
->removeColumn("externe_referenz")
->removeColumn("gesamtrabatt")
->save();
}
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class UpdateManualinvoicepositionStructure extends AbstractMigration
{
public function up(): void
{
if($this->getEnvironment() == "thetool") {
$table = $this->table("ManualInvoiceposition");
$table->addColumn("position_group", "string", ["null" => true, "default" => null, "length" => 255, "after" => "manualinvoice_id"]);
$table->removeColumn("start_date");
$table->removeColumn("end_date");
$table->removeColumn("billing_period");
$table->save();
}
}
public function down(): void
{
if($this->getEnvironment() == "thetool") {
$table = $this->table("ManualInvoiceposition");
$table->addColumn("start_date", "date", ["null" => false, "after" => "contract_id"]);
$table->addColumn("end_date", "date", ["null" => true, "default" => null, "after" => "start_date"]);
$table->addColumn("billing_period", "integer", ["null" => false, "default" => 0, "after" => "fibu_taxcode"]);
$table->removeColumn("position_group");
$table->save();
}
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class AddUnitToManualinvoiceposition extends AbstractMigration
{
public function up(): void
{
if($this->getEnvironment() == "thetool") {
$table = $this->table("ManualInvoiceposition");
$table->addColumn("unit", "string", [
"null" => false,
"default" => "Stk.",
"length" => 10,
"after" => "amount"
]);
$table->save();
}
}
public function down(): void
{
if($this->getEnvironment() == "thetool") {
$this->table("ManualInvoiceposition")->removeColumn("unit")->save();
}
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class CreateManualInvoiceJournal extends AbstractMigration
{
public function change(): void
{
if ($this->getEnvironment() == "thetool") {
$table = $this->table('ManualInvoiceJournal');
$table->addColumn('manualinvoiceId', 'integer', ['signed' => false])
->addColumn('text', 'text', ['null' => true])
->addColumn('data', 'json', ['null' => true])
->addColumn('statusChange', 'string', ['limit' => 255, 'null' => true])
->addColumn('fileIds', 'json', ['null' => true])
->addColumn('createBy', 'integer', ['signed' => false])
->addColumn('create', 'integer')
->addForeignKey('manualinvoiceId', 'ManualInvoice', 'id', ['delete' => 'CASCADE', 'update' => 'CASCADE'])
->addIndex(['manualinvoiceId'], ['name' => 'manualinvoiceId_idx'])
->create();
}
}
}

View File

@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class UpdateManualInvoiceStatusValues extends AbstractMigration
{
public function up(): void
{
if ($this->getEnvironment() == "thetool") {
// Change status column from enum to varchar to support new values
$this->execute("ALTER TABLE ManualInvoice MODIFY COLUMN status VARCHAR(50) NOT NULL DEFAULT 'erstellt'");
// Update existing values to new ones
$this->execute("UPDATE ManualInvoice SET status = 'erstellt' WHERE status = 'draft'");
$this->execute("UPDATE ManualInvoice SET status = 'gesendet' WHERE status = 'finalized'");
$this->execute("UPDATE ManualInvoice SET status = 'exportiert' WHERE status = 'exported'");
}
}
public function down(): void
{
if ($this->getEnvironment() == "thetool") {
// Revert values back
$this->execute("UPDATE ManualInvoice SET status = 'draft' WHERE status = 'erstellt'");
$this->execute("UPDATE ManualInvoice SET status = 'finalized' WHERE status = 'gesendet'");
$this->execute("UPDATE ManualInvoice SET status = 'exported' WHERE status = 'exportiert'");
// Change back to enum
$this->execute("ALTER TABLE ManualInvoice MODIFY COLUMN status ENUM('draft', 'finalized', 'exported') NOT NULL DEFAULT 'draft'");
}
}
}

View File

@@ -270,4 +270,15 @@
.preview-footer .page-number {
text-align: right;
}
/* Fix tt-select label to match tt-input styling */
.manual-invoice-overlay .tt-select-modern label.col-form-label {
font-weight: normal;
margin-bottom: 0.5rem;
}
/* Ensure consistent form-group spacing */
.manual-invoice-overlay .tt-select-modern.form-group {
margin-bottom: 1rem;
}

View File

@@ -4,7 +4,7 @@ Vue.component('manual-invoice', {
<div class="d-flex justify-content-between align-items-center mb-3">
<tt-button text="Neue Rechnung" icon="fas fa-plus" @click="openModal()" additional-class="btn-primary"/>
</div>
<tt-table-crud ref="table" emit-edit @edit="openModal($event)" @createGutschrift="openGutschriftModal($event)">
<tt-table-crud ref="table" emit-edit @edit="openModal($event)" @createGutschrift="openGutschriftModal($event)" @pdfPreview="handlePdfPreview($event)" @sendInvoice="handleSendInvoice($event)">
<template v-slot:total="{ row }">{{ formatPrice(row.total) }}</template>
<template v-slot:total_gross="{ row }">{{ formatPrice(row.total_gross) }}</template>
<template v-slot:invoice_date="{ row }">{{ formatDate(row.invoice_date) }}</template>
@@ -18,9 +18,10 @@ Vue.component('manual-invoice', {
</tt-table-crud>
<manual-invoice-modal v-if="isModalOpen" :initial-data="editingInvoiceData" @close="closeModal" @save="handleSave"/>
<gutschrift-modal v-if="isGutschriftModalOpen" :invoice-id="gutschriftInvoiceId" @close="closeGutschriftModal" @created="handleGutschriftCreated"/>
<send-invoice-modal v-if="isSendModalOpen" :invoice-id="sendInvoiceId" @close="closeSendModal" @sent="handleInvoiceSent"/>
</tt-card>
`,
data: () => ({ isModalOpen: false, editingInvoiceData: null, isGutschriftModalOpen: false, gutschriftInvoiceId: null }),
data: () => ({ isModalOpen: false, editingInvoiceData: null, isGutschriftModalOpen: false, gutschriftInvoiceId: null, isSendModalOpen: false, sendInvoiceId: null }),
methods: {
openModal(invoice = null) {
this.editingInvoiceData = invoice ? JSON.parse(JSON.stringify(invoice)) : null;
@@ -36,17 +37,17 @@ Vue.component('manual-invoice', {
const positions = invoiceData.positions.map(p => {
const amount = parseFloat(p.amount) || 0;
const price = parseFloat(p.price) || 0;
const discount = parseFloat(p.discount) || 0;
const vatrate = parseFloat(p.vatrate) || 0;
const priceAfterDiscount = amount * price * (1 - discount / 100);
return {
...p, amount, price, vatrate,
price_total: amount * price,
price_gross: (amount * price) * (1 + vatrate / 100),
...p, amount, price, discount, vatrate,
unit: p.unit || 'Stk.',
price_total: priceAfterDiscount,
price_gross: priceAfterDiscount * (1 + vatrate / 100),
product_id: p.product_id || 0,
contract_id: p.contract_id || 0,
billing_id: p.billing_id || null,
billing_period: p.billing_period || 0,
start_date: p.start_date || moment().format('YYYY-MM-DD'),
end_date: p.end_date || null,
matchcode: p.matchcode || null,
fibu_cost_account: p.fibu_cost_account || null,
fibu_cost_account_legacy: p.fibu_cost_account_legacy || null,
@@ -66,7 +67,8 @@ Vue.component('manual-invoice', {
billing_delivery: 'email',
fibu_payment_due: 14,
fibu_account_number: invoiceData.fibu_account_number || 0,
vatgroup_id: 1
vatgroup_id: 1,
gesamtrabatt: parseFloat(invoiceData.gesamtrabatt) || 0
};
const url = invoiceData.id ? window.TT_CONFIG.UPDATE_URL : window.TT_CONFIG.CREATE_URL;
@@ -93,8 +95,33 @@ Vue.component('manual-invoice', {
},
closeGutschriftModal() { this.isGutschriftModalOpen = false; this.gutschriftInvoiceId = null; },
handleGutschriftCreated() { this.closeGutschriftModal(); this.$refs.table.$refs.table.refreshTable(); },
getStatusClass(s) { return { 'draft': 'badge badge-secondary', 'finalized': 'badge badge-success', 'exported': 'badge badge-primary' }[s] || 'badge badge-secondary'; },
getStatusText(s) { return { 'draft': 'Entwurf', 'finalized': 'Finalisiert', 'exported': 'Exportiert' }[s] || s; }
async handlePdfPreview(invoice) {
try {
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/ManualInvoice/pdfPreview`, { id: invoice.id });
if (data.success && data.url) {
window.open(data.url, '_blank');
} else {
window.notify('error', data.message || 'Fehler beim Öffnen der PDF Vorschau');
}
} catch (e) {
console.error('Error opening PDF preview:', e);
window.notify('error', 'Fehler: ' + (e.response?.data?.message || e.message));
}
},
handleSendInvoice(invoice) {
this.sendInvoiceId = invoice.id;
this.isSendModalOpen = true;
},
closeSendModal() {
this.isSendModalOpen = false;
this.sendInvoiceId = null;
},
handleInvoiceSent() {
this.closeSendModal();
this.$refs.table.$refs.table.refreshTable();
},
getStatusClass(s) { return { 'erstellt': 'badge badge-secondary', 'gesendet': 'badge badge-success', 'exportiert': 'badge badge-primary' }[s] || 'badge badge-secondary'; },
getStatusText(s) { return { 'erstellt': 'Erstellt', 'gesendet': 'Gesendet', 'exportiert': 'Exportiert' }[s] || s; }
}
});
@@ -117,15 +144,18 @@ Vue.component('manual-invoice-modal', {
</tt-card>
<tt-card><template v-slot:header><h5><i class="fas fa-file-invoice mr-2"></i>Rechnungsdetails</h5></template>
<div class="form-grid">
<tt-input label="Rechnungsnr." v-model="invoiceData.invoice_number" sm/>
<tt-date-picker label="Rechnungsdatum" v-model="invoiceData.invoice_date" :date-range="false" sm/>
<tt-input label="Rechnungsdatum" type="date" v-model="invoiceData.invoice_date" sm/>
<tt-select label="Zahlungsart" v-model="invoiceData.billing_type" :options="billingTypeOptions" sm/>
</div>
<tt-input label="Leistungszeitraum" v-model="invoiceData.leistungszeitraum" sm row placeholder="z.B. 01.01.2025 - 31.01.2025"/>
<tt-input label="Externe Referenz" v-model="invoiceData.externe_referenz" sm row placeholder="z.B. Auftragsnummer, Bestellnummer"/>
<tt-textarea label="Einleitender Text" v-model="invoiceData.einleitender_text" rows="3" sm row/>
</tt-card>
<tt-card><template v-slot:header><h5><i class="fas fa-list-ol mr-2"></i>Positionen</h5></template>
<tt-positions-manager ref="positionsManager" v-model="invoiceData.positions" :config="positionsConfig" />
<tt-positions-manager group-mode ref="positionsManager" v-model="invoiceData.positions" :config="positionsConfig" />
</tt-card>
<tt-card><template v-slot:header><h5><i class="fas fa-paragraph mr-2"></i>Texte</h5></template>
<tt-card><template v-slot:header><h5><i class="fas fa-paragraph mr-2"></i>Texte & Rabatt</h5></template>
<tt-input label="Gesamtrabatt (%)" v-model.number="invoiceData.gesamtrabatt" sm row type="number" placeholder="0"/>
<tt-textarea label="Steuerhinweis" v-model="invoiceData.tax_text" rows="2"/>
</tt-card>
</div>
@@ -150,20 +180,31 @@ Vue.component('manual-invoice-modal', {
pdfPreviewUrl: '',
previewDebounceTimer: null,
invoiceData: {
id: null, invoice_number: `MRN${new Date().getFullYear()}-X000001`, invoice_date: moment().unix(),
id: null, invoice_number: null, invoice_date: moment().format('YYYY-MM-DD'),
billingaddress_id: null, owner_id: null, customer_number: 0, fibu_account_number: 0,
company: '', firstname: '', lastname: '', street: '', zip: '', city: '', country: 'Österreich',
uid: '', email: '', billing_type: 'invoice', tax_text: '', positions: [], total: 0, total_gross: 0
uid: '', email: '', billing_type: 'invoice', tax_text: '',
leistungszeitraum: '', einleitender_text: '', externe_referenz: '', gesamtrabatt: 0,
positions: [], total: 0, total_gross: 0
},
billingTypeOptions: [{value: 'invoice', text: 'Rechnung'}, {value: 'sepa', text: 'SEPA'}],
positionsConfig: {
fields: {
product_name: { type: 'input', label: 'Bezeichnung' },
product_info: { type: 'input', label: 'Zusatzinfo' },
start_date: { type: 'input', label: 'Start', inputType: 'date' },
end_date: { type: 'input', label: 'Ende', inputType: 'date' },
amount: { type: 'input', label: 'Menge', inputType: 'number' },
unit: {
type: 'select',
label: 'Einheit',
options: [
{ value: 'Pau.', text: 'Pau.' },
{ value: 'Stk.', text: 'Stk.' },
{ value: 'h', text: 'h' },
{ value: 'm', text: 'm' }
]
},
price: { type: 'input', label: 'Einzelpreis (€)', inputType: 'number' },
discount: { type: 'input', label: 'Rabatt (%)', inputType: 'number' },
vatrate: { type: 'input', label: 'USt. (%)', inputType: 'number' },
},
validateForm: (d) => {
@@ -178,14 +219,33 @@ Vue.component('manual-invoice-modal', {
computed: {
overlayClasses() { return { 'preview-active-small': !this.isLargeScreen && this.showPreviewOnSmallScreen, 'editor-active-small': !this.isLargeScreen && !this.showPreviewOnSmallScreen }; },
totals() {
let net = 0, vat = {};
let subtotal = 0;
(this.invoiceData.positions || []).forEach(p => {
const lineTotal = (parseFloat(p.amount) || 0) * (parseFloat(p.price) || 0);
const r = parseInt(p.vatrate) || 0;
net += lineTotal;
vat[r] = (vat[r] || 0) + lineTotal * (r / 100);
const amount = parseFloat(p.amount) || 0;
const price = parseFloat(p.price) || 0;
const discount = parseFloat(p.discount) || 0;
const lineTotal = amount * price * (1 - discount / 100);
subtotal += lineTotal;
});
return { net, vat, gross: net + Object.values(vat).reduce((a, b) => a + b, 0) };
// Apply gesamtrabatt
const gesamtrabatt = parseFloat(this.invoiceData.gesamtrabatt) || 0;
const net = subtotal * (1 - gesamtrabatt / 100);
// Calculate VAT
let vat = {}, gross = 0;
(this.invoiceData.positions || []).forEach(p => {
const amount = parseFloat(p.amount) || 0;
const price = parseFloat(p.price) || 0;
const discount = parseFloat(p.discount) || 0;
const r = parseInt(p.vatrate) || 0;
const lineNet = amount * price * (1 - discount / 100) * (1 - gesamtrabatt / 100);
const lineVat = lineNet * (r / 100);
vat[r] = (vat[r] || 0) + lineVat;
gross += lineNet + lineVat;
});
return { subtotal, net, vat, gross };
}
},
watch: {
@@ -249,12 +309,16 @@ Vue.component('manual-invoice-modal', {
async updatePdfPreview() {
this.pdfLoading = true;
try {
const positions = this.invoiceData.positions.map(p => {
const amount = parseFloat(p.amount) || 0;
const price = parseFloat(p.price) || 0;
const vatrate = parseFloat(p.vatrate) || 0;
return { ...p, amount, price, vatrate, price_total: amount * price, price_gross: (amount * price) * (1 + vatrate / 100) };
});
const positions = this.invoiceData.positions
.filter(p => p.product_name && (parseFloat(p.amount) || 0) > 0) // Filter out empty positions
.map(p => {
const amount = parseFloat(p.amount) || 0;
const price = parseFloat(p.price) || 0;
const discount = parseFloat(p.discount) || 0;
const vatrate = parseFloat(p.vatrate) || 0;
const priceAfterDiscount = amount * price * (1 - discount / 100);
return { ...p, amount, price, discount, vatrate, unit: p.unit || 'Stk.', price_total: priceAfterDiscount, price_gross: priceAfterDiscount * (1 + vatrate / 100) };
});
const payload = {
preview: true, ...this.invoiceData,
@@ -349,4 +413,127 @@ Vue.component('gutschrift-modal', {
close() { this.$emit('close'); },
formatPrice(v) { return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(v || 0); }
}
});
Vue.component('send-invoice-modal', {
props: ['invoiceId'],
template: `
<tt-modal :show="true" @close="close" @submit="handleAction" :submit-text="actionButtonText" :is-loading="loading" size="md">
<template v-slot:header>
<h5><i class="fas fa-paper-plane mr-2"></i>Rechnung aussenden</h5>
</template>
<div v-if="!invoice" class="text-center py-4">
<tt-loader />
</div>
<div v-else>
<div class="mb-3">
<strong>Rechnung:</strong> {{ invoice.invoice_number }}<br/>
<strong>Kunde:</strong> {{ invoice.customerName }}
</div>
<hr/>
<div class="form-group">
<label>Aktion auswählen:</label>
<div class="form-check">
<input class="form-check-input" type="radio" id="action-email" value="email" v-model="selectedAction" :disabled="!invoice.email">
<label class="form-check-label" for="action-email">
<i class="fas fa-envelope mr-2"></i>Per E-Mail versenden
<span v-if="invoice.email" class="text-muted d-block ml-4">an {{ invoice.email }}</span>
<span v-else class="text-danger d-block ml-4">Keine E-Mail-Adresse vorhanden</span>
</label>
</div>
<div class="form-check mt-2">
<input class="form-check-input" type="radio" id="action-download" value="download" v-model="selectedAction">
<label class="form-check-label" for="action-download">
<i class="fas fa-download mr-2"></i>PDF herunterladen
</label>
</div>
</div>
<div v-if="selectedAction === 'email' && invoice.email" class="mt-3">
<tt-input label="E-Mail-Adresse" v-model="emailAddress" sm/>
</div>
</div>
</tt-modal>
`,
data() {
return {
invoice: null,
loading: false,
selectedAction: 'email',
emailAddress: ''
};
},
computed: {
actionButtonText() {
return this.selectedAction === 'email' ? 'E-Mail versenden' : 'Herunterladen';
}
},
async mounted() {
try {
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/ManualInvoice/getInvoiceEmail`, { id: this.invoiceId });
if (data.success) {
this.invoice = data.invoice;
this.emailAddress = data.invoice.email || '';
this.selectedAction = data.invoice.email ? 'email' : 'download';
} else {
window.notify('error', data.message || 'Fehler beim Laden der Rechnung');
this.close();
}
} catch (e) {
window.notify('error', 'Fehler beim Laden der Rechnung');
this.close();
}
},
methods: {
async handleAction() {
if (this.selectedAction === 'email') {
await this.sendEmail();
} else {
await this.downloadPdf();
}
},
async sendEmail() {
if (!this.emailAddress) {
window.notify('error', 'Bitte E-Mail-Adresse eingeben');
return;
}
this.loading = true;
try {
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/ManualInvoice/sendInvoiceEmail`, {
id: this.invoiceId,
email: this.emailAddress
});
if (data.success) {
window.notify('success', data.message);
this.$emit('sent');
} else {
window.notify('error', data.message || 'Fehler beim Versenden');
}
} catch (e) {
window.notify('error', 'Fehler: ' + (e.response?.data?.message || e.message));
} finally {
this.loading = false;
}
},
async downloadPdf() {
this.loading = true;
try {
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/ManualInvoice/downloadInvoice`, {
id: this.invoiceId
});
if (data.success && data.url) {
window.location.href = data.url;
this.$emit('sent');
} else {
window.notify('error', data.message || 'Fehler beim Download');
}
} catch (e) {
window.notify('error', 'Fehler: ' + (e.response?.data?.message || e.message));
} finally {
this.loading = false;
}
},
close() {
this.$emit('close');
}
}
});