warehouseoffer v2

This commit is contained in:
Luca Haid
2025-07-15 12:57:41 +02:00
parent 5609e73923
commit 11e4d4b270
8 changed files with 1655 additions and 660 deletions

View File

@@ -1,81 +1,63 @@
<?php
/**
* Template for Warehouse Offer PDF Main Content
*
* @var \App\Models\WarehouseOfferModel $offer The offer object
* @var array $entries Grouped positions array [groupKey => [position1, position2,...]]
* @var float $subTotal Calculated subtotal of all positions
* @var string $offerNumber Offer number
* @var int $offerDate Timestamp of offer creation
* @var int|null $validUntilDate Timestamp of offer validity end, or null
* @var bool $includeTax Whether to calculate and show VAT
* @var float $vatRate The VAT rate (e.g., 0.20 for 20%)
* @var string $offerText Additional text from the offer record
*
* // Bank details are also available but typically used in footer
* @var string $bank_iban
* @var string $bank_bic
* @var string $bank_bank
* @var string $bank_owner
* All variables are passed from WarehouseOfferController->createPDFAction
*/
// Set filename for download (optional, can also be set in controller)
// $this->setReturnValue(['filename' => ($offerNumber ?? $offer->id) . "_Angebot.pdf"]);
// --- Helper Functions ---
function formatPrice($price, $currency = '€') {
return number_format($price, 2, ',', '.') . ' ' . $currency;
}
// --- Text Elements (Simple German Example - Extend for EN like in Order) ---
// --- Text Elements ---
$texts = [
'DE' => [
'title' => 'Angebot',
'offerNumberLabel' => 'Angebotsnr.:',
'offerDateLabel' => 'Datum:',
'editorLabel' => 'Sachbearbeiter:',
'usageLabel' => 'Zweck:',
'customerReferenceLabel' => 'Kundenreferenz:',
'validUntilLabel' => 'Gültig bis:',
'vatLabel' => 'USt-IdNr.:',
'pageLabel' => 'Seite', // For page numbering in content if needed
'table' => [
'pos' => 'Pos',
'article' => 'Artikel / Beschreibung',
// 'description' => 'Beschreibung', // Combined with Article
'amount' => 'Menge',
'unit' => 'Einheit',
'unitPrice' => 'Einzelpreis',
'totalPrice' => 'Gesamtpreis'
],
'paymentTerms' => [
'net30' => 'Zahlungsbedingungen: 30 Tage netto',
'net60' => 'Zahlungsbedingungen: 60 Tage netto',
'immediate' => 'Zahlungsbedingungen: Sofort fällig'
],
'summary' => [
'subTotal' => 'Nettobetrag',
'vatFormatted' => 'zzgl. {VAT_RATE}% MwSt.', // Placeholder for rate
'discount' => 'Rabatt',
'vatFormatted' => 'zzgl. {VAT_RATE}% MwSt.',
'total' => 'Gesamtbetrag',
'currency' => '€' // Currency symbol
'alternativeTotal' => 'Summe Alternativpositionen'
],
'notes' => 'Anmerkungen:',
'alternativeHeader' => 'Alternativpositionen',
'notes' => 'Anmerkungen & Konditionen',
'defaultOfferText' => 'Vielen Dank für Ihre Anfrage. Es gelten unsere Allgemeinen Geschäftsbedingungen.',
]
// Add 'EN' => [...] section if needed
];
// Simple language selection (default to DE) - enhance if customer language is known
$lang = 'DE';
$text = $texts[$lang];
$currencySymbol = $text['summary']['currency'];
// --- Calculations ---
$vatAmount = 0;
$grandTotal = $subTotal;
if ($includeTax) {
$vatAmount = $subTotal * $vatRate;
$grandTotal = $subTotal + $vatAmount;
$discountAmount = 0;
$subTotalAfterDiscount = $subTotal;
if (isset($offer->totalDiscount) && $offer->totalDiscount > 0) {
$discountPercentage = $offer->totalDiscount;
$discountAmount = ($subTotal * $discountPercentage) / 100;
$subTotalAfterDiscount = $subTotal - $discountAmount;
}
$vatAmount = 0;
$grandTotal = $subTotalAfterDiscount;
if ($includeTax) {
$vatAmount = $subTotalAfterDiscount * $vatRate;
$grandTotal = $subTotalAfterDiscount + $vatAmount;
}
// Format dates
$formattedOfferDate = date("d.m.Y", $offerDate);
$formattedValidUntil = $validUntilDate ? date("d.m.Y", $validUntilDate) : date("d.m.Y", strtotime("+14 days", $offerDate));
$formattedValidUntil = date("d.m.Y", strtotime("+14 days", $offerDate));
?>
<!DOCTYPE html>
@@ -84,140 +66,41 @@ $formattedValidUntil = $validUntilDate ? date("d.m.Y", $validUntilDate) : date("
<title><?= $text['title'] ?> <?= $offerNumber ?></title>
<meta charset="utf-8"/>
<style>
body {
font-family: "Open Sans", sans-serif, Verdana;
font-size: 10px; /* Slightly smaller base font */
color: #333;
}
h1 {
text-align: center;
color: #005384; /* Xinon Blue */
font-size: 18px;
margin-bottom: 20px;
}
.header-info {
margin-bottom: 20px;
font-size: 11px;
}
.header-info table {
width: 100%;
border-collapse: collapse;
}
.header-info td {
padding: 2px 5px;
}
.header-info .label {
font-weight: bold;
text-align: right;
padding-right: 10px;
width: 100px; /* Fixed width for labels */
}
body { font-family: "Open Sans", sans-serif, Verdana; font-size: 10px; color: #333; }
h1 { text-align: center; color: #005384; font-size: 18px; margin-bottom: 20px; }
.header-info table { width: 100%; border-collapse: collapse; font-size: 11px; margin-bottom: 20px; }
.header-info td { padding: 2px 5px; }
.header-info .label { font-weight: bold; text-align: right; padding-right: 10px; width: 120px; }
table#positionsTable {
width: 100%;
border-collapse: collapse;
margin-top: 15px;
margin-bottom: 15px;
}
table#positionsTable th {
border-bottom: 2px solid #005384;
padding: 8px 4px;
text-align: left;
background-color: #f2f2f2;
font-size: 10px;
}
table#positionsTable td {
border-bottom: 1px solid #e1e1e1;
padding: 6px 4px;
vertical-align: top;
}
table#positionsTable tr:last-child td {
border-bottom: none;
}
#positionsTable { width: 100%; border-collapse: collapse; margin-top: 15px; margin-bottom: 15px; }
#positionsTable th { border-bottom: 2px solid #005384; padding: 8px 4px; text-align: left; background-color: #f2f2f2; font-size: 10px; }
#positionsTable td { border-bottom: 1px solid #e1e1e1; padding: 6px 4px; vertical-align: top; }
/* Column Alignment */
table#positionsTable th.pos, table#positionsTable td.pos { text-align: center; width: 30px; }
table#positionsTable th.amount, table#positionsTable td.amount { text-align: right; width: 50px;}
table#positionsTable th.unit, table#positionsTable td.unit { text-align: center; width: 40px;}
table#positionsTable th.price, table#positionsTable td.price { text-align: right; width: 80px;}
table#positionsTable th.total, table#positionsTable td.total { text-align: right; width: 90px;}
table#positionsTable th.article, table#positionsTable td.article { text-align: left; }
#positionsTable th.pos, #positionsTable td.pos { text-align: center; width: 30px; }
#positionsTable th.amount, #positionsTable td.amount { text-align: right; width: 50px;}
#positionsTable th.unit, #positionsTable td.unit { text-align: center; width: 40px;}
#positionsTable th.price, #positionsTable td.price { text-align: right; width: 80px;}
#positionsTable th.total, #positionsTable td.total { text-align: right; width: 90px;}
.position-group-header td {
background-color: #e8f0f8; /* Light blue for group headers */
font-weight: bold;
border-top: 1px solid #ccc;
border-bottom: 1px solid #ccc;
padding: 4px;
}
.position-group-header td { background-color: #e8f0f8; font-weight: bold; border-top: 1px solid #ccc; border-bottom: 1px solid #ccc; padding: 4px; }
.alternative-group-header td { background-color: #fff8e1; font-weight: bold; font-style: italic; }
.article-title {
font-weight: bold;
}
.article-description {
font-size: 9px; /* Smaller font for description */
color: #555;
padding-left: 10px;
margin-top: 2px;
}
table#summaryTable {
width: 45%; /* Adjust width as needed */
float: right; /* Align to the right */
border-collapse: collapse;
margin-top: 10px;
font-size: 11px;
}
table#summaryTable td {
padding: 5px 8px;
}
table#summaryTable td.label {
text-align: right;
font-weight: bold;
}
table#summaryTable td.value {
text-align: right;
}
table#summaryTable tr.grand-total td {
border-top: 1px solid #333;
border-bottom: 3px double #333; /* Double line for grand total */
font-weight: bold;
font-size: 12px;
}
table#summaryTable tr.subtotal td {
border-top: 1px solid #ccc;
}
.offer-text {
margin-top: 30px;
padding-top: 15px;
border-top: 1px solid #e1e1e1;
font-size: 10px;
clear: both; /* Ensure it clears the floated summary table */
}
.offer-text p {
margin: 5px 0;
}
.tax-info {
font-style: italic;
font-size: 9px;
margin-top: 5px;
text-align: right;
}
/* Page break avoidance */
tr, .position-group-header {
page-break-inside: avoid;
}
h1, .header-info, table#summaryTable, .offer-text {
page-break-before: auto;
page-break-after: auto;
}
table#positionsTable {
page-break-inside: auto;
}
.article-title { font-weight: bold; }
.article-description { font-size: 9px; color: #555; padding-left: 10px; margin-top: 2px; }
.position-comment { font-size: 9px; color: #444; padding-left: 10px; margin-top: 4px; font-style: italic; }
.alternative-position { font-style: italic; color: #555; }
/* The summary table no longer needs to float */
#summaryTable { width: 100%; border-collapse: collapse; margin-top: 10px; font-size: 11px; }
#summaryTable td { padding: 5px 8px; }
#summaryTable td.label { text-align: right; font-weight: bold; }
#summaryTable td.value { text-align: right; }
#summaryTable tr.grand-total td { border-top: 1px solid #333; border-bottom: 3px double #333; font-weight: bold; font-size: 12px; }
#summaryTable tr.subtotal td { border-top: 1px solid #ccc; }
.footer-container { page-break-inside: avoid; }
.offer-text { padding-top: 20px; border-top: 1px solid #eee; margin-top: 20px; font-size: 10px; }
.alternative-summary { font-size: 10px; padding: 5px; border: 1px dashed #ccc; background-color: #fafafa; }
</style>
</head>
<body>
@@ -234,25 +117,10 @@ $formattedValidUntil = $validUntilDate ? date("d.m.Y", $validUntilDate) : date("
</tr>
<tr>
<td class="label" style="text-align: left"><?= $text['editorLabel'] ?></td>
<td><?= $offerEditorName ?></td>
<td><?= htmlspecialchars($offerEditorName) ?></td>
<td class="label"><?= $text['validUntilLabel'] ?></td>
<td><?= $formattedValidUntil ?></td>
</tr>
<tr>
<td class="label" style="text-align: left"><?= $text['usageLabel'] ?></td>
<td><?= $offer->purpose ?? 'Keine Angabe' ?></td>
<?php if (!empty($offer->customerVAT)) : ?>
<td class="label"><?= $text['vatLabel'] ?></td>
<td><?= htmlspecialchars($offer->customerVAT) ?></td>
<?php endif; ?>
</tr>
<tr>
<td class="label" style="text-align: left"><?= $text['customerReferenceLabel'] ?></td>
<td><?= $offer->reference ?? 'Keine Angabe' ?></td>
</tr>
</table>
</div>
@@ -271,121 +139,85 @@ $formattedValidUntil = $validUntilDate ? date("d.m.Y", $validUntilDate) : date("
<?php
$posCounter = 0;
foreach ($entries as $groupName => $positions):
// Optional: Display group name if it exists
$isAlternativeGroup = ($groupName === 'Alternativpositionen');
if (!empty($groupName)): ?>
<tr class="position-group-header">
<td colspan="6"><?= htmlspecialchars($groupName) ?></td>
<tr class="position-group-header <?= $isAlternativeGroup ? 'alternative-group-header' : '' ?>">
<td colspan="6"><?= htmlspecialchars($isAlternativeGroup ? $text['alternativeHeader'] : $groupName) ?></td>
</tr>
<?php endif;
foreach ($positions as $p):
if ($p['amount'] == 0) continue;
$posCounter++;
$rowClass = $isAlternativeGroup ? 'alternative-position' : '';
?>
<tr>
<tr class="<?= $rowClass ?>">
<td class="pos"><?= $posCounter ?></td>
<td class="article">
<div class="article-title">
<?php if (!empty($p['articleNumber'])): ?>
<strong><?= htmlspecialchars($p['articleNumber']) ?></strong> |
<?php endif; ?>
<?= htmlspecialchars($p['articleText']) ?></div>
<?php if (!empty($p['articleDescription'])): ?>
<div class="article-description"><?= nl2br(htmlspecialchars($p['articleDescription'])) ?></div>
<?php endif; ?>
<div class="article-title"><?= htmlspecialchars($p['articleText']) ?></div>
<?php if (!empty($p['articleDescription'])): ?><div class="article-description"><?= nl2br(htmlspecialchars($p['articleDescription'])) ?></div><?php endif; ?>
<?php if (!empty($p['comment'])): ?><div class="position-comment"><strong>Kommentar:</strong> <?= nl2br(htmlspecialchars($p['comment'])) ?></div><?php endif; ?>
</td>
<td class="amount"><?= number_format($p['amount'], 2, ',', '.') // Format amount as needed ?></td>
<td class="amount"><?= number_format($p['amount'], 2, ',', '.') ?></td>
<td class="unit"><?= htmlspecialchars($p['articleUnit']) ?></td>
<td class="price"><?= number_format($p['price'], 2, ',', '.') ?> <?= $currencySymbol ?></td>
<td class="total"><?= number_format($p['totalPrice'], 2, ',', '.') ?> <?= $currencySymbol ?></td>
<td class="price"><?= formatPrice($p['price'], '€') ?></td>
<td class="total"><?= formatPrice($p['totalPrice'], '€') ?></td>
</tr>
<?php endforeach; // End positions loop ?>
<?php endforeach; // End entries (groups) loop ?>
<?php if ($posCounter == 0): // Show message if no positions ?>
<tr>
<td colspan="6" style="text-align: center; padding: 20px;">Keine Positionen im Angebot enthalten.</td>
</tr>
<?php endif; ?>
<?php endforeach; ?>
<?php endforeach; ?>
</tbody>
</table>
<?php
// --- CALCULATION LOGIC ---
// Initialize discount variables
$discountAmount = 0;
$discountPercentage = 0;
$subTotalAfterDiscount = $subTotal; // By default, the same as the original subtotal
// Check if a discount exists and calculate the new amounts
if (isset($offer->totalDiscount) && $offer->totalDiscount > 0) {
$discountPercentage = $offer->totalDiscount;
// Calculate the monetary value of the discount
$discountAmount = ($subTotal * $discountPercentage) / 100;
// Calculate the subtotal after applying the discount
$subTotalAfterDiscount = $subTotal - $discountAmount;
}
// Recalculate VAT and Grand Total based on the potentially discounted subtotal
if ($includeTax) {
// $vatRate should be a float, e.g., 0.20 for 20%
$vatAmount = $subTotalAfterDiscount * $vatRate;
$grandTotal = $subTotalAfterDiscount + $vatAmount;
} else {
$vatAmount = 0; // Ensure VAT is zero if tax is not included
$grandTotal = $subTotalAfterDiscount;
}
// --- DISPLAY LOGIC ---
?>
<table id="summaryTable">
<tbody>
<tr class="subtotal">
<td class="label"><?= $text['summary']['subTotal'] ?>:</td>
<td class="value"><?= number_format($subTotal, 2, ',', '.') ?> <?= $currencySymbol ?></td>
</tr>
<?php
// Display the discount row only if a discount has been applied
if ($discountAmount > 0):
// Define a label for the discount. You can add 'discount' to your $text array.
$discountLabel = $text['summary']['discount'] ?? 'Rabatt';
?>
<div class="footer-container">
<table style="width: 100%;">
<tbody>
<tr>
<td class="label"><?= $discountLabel ?> (<?= number_format($discountPercentage, 0) ?>%):</td>
<td class="value">-<?= number_format($discountAmount, 2, ',', '.') ?> <?= $currencySymbol ?></td>
<td style="width: 55%; vertical-align: top; padding-right: 20px;">
<?php if ($alternativeTotal > 0): ?>
<div class="alternative-summary">
<strong><?= $text['summary']['alternativeTotal'] ?>:</strong>
<span style="float: right;"><?= formatPrice($alternativeTotal, '€') ?></span>
<br>
<small>(nicht im Gesamtbetrag enthalten)</small>
</div>
<?php endif; ?>
</td>
<td style="width: 45%; vertical-align: top;">
<table id="summaryTable">
<tbody>
<tr class="subtotal">
<td class="label">Zwischensumme:</td>
<td class="value"><?= formatPrice($subTotal, '€') ?></td>
</tr>
<?php if ($discountAmount > 0): ?>
<tr>
<td class="label"><?= $text['summary']['discount'] ?> (<?= number_format($discountPercentage, 0) ?>%):</td>
<td class="value">-<?= formatPrice($discountAmount, '€') ?></td>
</tr>
<?php endif; ?>
<?php if ($includeTax):
$vatLabel = str_replace('{VAT_RATE}', number_format($vatRate * 100, 0), $text['summary']['vatFormatted']);
?>
<tr>
<td class="label"><?= $vatLabel ?>:</td>
<td class="value"><?= formatPrice($vatAmount, '€') ?></td>
</tr>
<?php endif; ?>
<tr class="grand-total">
<td class="label"><?= $text['summary']['total'] ?>:</td>
<td class="value"><?= formatPrice($grandTotal, '€') ?></td>
</tr>
</tbody>
</table>
</td>
</tr>
<?php endif; ?>
</tbody>
</table>
<?php
// Display the VAT row if tax is included
if ($includeTax):
$vatLabel = str_replace('{VAT_RATE}', number_format($vatRate * 100, 0), $text['summary']['vatFormatted']);
?>
<tr>
<td class="label"><?= $vatLabel ?>:</td>
<td class="value"><?= number_format($vatAmount, 2, ',', '.') ?> <?= $currencySymbol ?></td>
</tr>
<?php endif; ?>
<tr class="grand-total">
<td class="label"><?= $text['summary']['total'] ?>:</td>
<td class="value"><?= number_format($grandTotal, 2, ',', '.') ?> <?= $currencySymbol ?></td>
</tr>
</tbody>
</table>
<div class="offer-text">
<?php if (!empty($offerText)): ?>
<p><strong><?= $text['notes'] ?></strong></p>
<div><?= nl2br(htmlspecialchars($offerText)) // Use nl2br to preserve line breaks ?></div>
<br>
<?php endif; ?>
<p><?= $text['defaultOfferText'] // Add default closing text ?></p>
<p><?= $text['paymentTerms'][$offer->paymentTerms] ?? $text['paymentTerms']['immediate'] ?></p>
<p>Lieferzeit: nach Vereinbarung.</p>
<p><?= nl2br(htmlspecialchars($offer->closingText ?? '')) ?></p>
<div class="offer-text">
<strong><?= $text['notes'] ?></strong>
<div><?= nl2br(htmlspecialchars($closingText)) ?></div>
</div>
</div>
</body>

View File

@@ -87,7 +87,7 @@ class PreorderIFrameController extends mfBaseController
$tt_network = NetworkModel::getFirst(['adb_network_id' => $preorderData['additionalData']['clusterId']]);
if ($tt_network) $networkId = $tt_network->id;
} elseif (!empty($preorderData['additionalData']['gemeindeId'])) {
$gn = GemeindeNetzgebietModel::getFirst(['gemeinde_id' => $preorderData['additionalData']['gemeindeId']]);
$gn = ADBGemeindeNetzgebietModel::getFirst(['gemeinde_id' => $preorderData['additionalData']['gemeindeId']]);
if ($gn) {
$tt_network = NetworkModel::getFirst(['adb_netzgebiet_id' => $gn->netzgebiet_id]);
if ($tt_network) $networkId = $tt_network->id;

View File

@@ -6,14 +6,16 @@ class WarehouseHistoryModel {
public string $table;
public int $row_id;
public string $key;
public string $old_value;
public string $new_value;
public string $note;
public ?string $old_value;
public ?string $new_value;
public ?string $note;
public ?string $data; // Added for version snapshots
public int $user_id;
public string $user_name;
public ?string $user_name;
public int $create;
public function __construct($data = []) {
if (empty($data)) return;
foreach ($data as $field => $value) {
if (property_exists(get_called_class(), $field)) {
$this->$field = $value;
@@ -25,26 +27,26 @@ class WarehouseHistoryModel {
$FronkDB = FronkDB::singleton();
$db = $FronkDB->link;
$dataArr = [
$data["table"],
$data["row_id"],
$data["key"],
$data["old_value"],
$data["new_value"],
$data["note"],
$data["user_id"],
$data["create"]
];
// Define all possible columns to handle dynamic data insertion
$columns = ['table', 'row_id', 'key', 'old_value', 'new_value', 'note', 'data', 'user_id', 'create'];
$sqlColumns = [];
$sqlValues = [];
$sqlValueStr = "(" . implode(", ", array_map(function ($item) use ($db) {
return "'" . $db->real_escape_string($item) . "'";
}, $dataArr)) . ")";
foreach ($columns as $column) {
if (isset($data[$column])) {
$sqlColumns[] = "`$column`";
$sqlValues[] = "'" . $db->real_escape_string($data[$column]) . "'";
}
}
$sql = /** @lang text */ "INSERT INTO `WarehouseHistory` (`table`, `row_id`, `key`, `old_value`, `new_value`, `note`, `user_id`, `create`) VALUES $sqlValueStr";
if (empty($sqlColumns)) return false; // Nothing to insert
$sql = "INSERT INTO `WarehouseHistory` (" . implode(', ', $sqlColumns) . ") VALUES (" . implode(', ', $sqlValues) . ")";
$db->query($sql) or die($db->error);
return $db->insert_id;
}
/**
* Retrieves an array of WarehouseHistoryModel objects by row ID.
*
@@ -53,19 +55,49 @@ class WarehouseHistoryModel {
* @return WarehouseHistoryModel[] Array of WarehouseHistoryModel objects.
*/
public static function getByRowId(int $id, string $table): array {
$db = FronkDB::singleton();
$id = $db->escape($id);
$sql = /** @lang text */ "SELECT WH.*, W.name as user_name FROM `WarehouseHistory` WH
$db = FronkDB::singleton()->link;
$id = $db->real_escape_string($id);
$table = $db->real_escape_string($table);
$sql = "SELECT WH.*, W.name as user_name FROM `WarehouseHistory` WH
LEFT JOIN `Worker` W ON WH.user_id = W.id
WHERE `row_id` = $id AND `table` = '$table' ORDER BY `create` DESC";
WHERE WH.`row_id` = '$id' AND WH.`table` = '$table' ORDER BY WH.`create` DESC";
$result = $db->query($sql);
$rows = [];
while ($row = $result->fetch_assoc()) {
$rows[] = new WarehouseHistoryModel($row);
}
return $rows;
}
/**
* Retrieves a single history entry for a specific version.
*
* @param int $rowId The ID of the row in the original table (e.g., offer ID).
* @param string $table The name of the original table (e.g., 'WarehouseOffer').
* @param int $version The version number to retrieve.
* @return ?self Returns a WarehouseHistoryModel object or null if not found.
*/
public static function getOneByVersion(int $rowId, string $table, int $version): ?self
{
$db = FronkDB::singleton()->link;
$rowId = $db->real_escape_string($rowId);
$table = $db->real_escape_string($table);
$version = $db->real_escape_string($version);
$sql = "SELECT * FROM `WarehouseHistory`
WHERE `row_id` = '$rowId'
AND `table` = '$table'
AND `key` = 'version'
AND `new_value` = '$version'
ORDER BY `create` DESC
LIMIT 1";
$result = $db->query($sql);
if ($result && $result->num_rows > 0) {
return new self($result->fetch_assoc());
}
return null;
}
}

View File

@@ -1,326 +1,484 @@
<?php
class WarehouseOfferController extends TTCrud {
class WarehouseOfferController extends TTCrud
{
protected string $headerTitle = 'Angebote';
protected string $singleText = 'Angebot';
protected bool $createText = false;
protected array $columns = [
['key' => 'id', 'text' => 'ID', 'modal' => false, 'table' => false],
['key' => 'version', 'text' => 'Version', 'modal' => false, 'table' => false],
['key' => 'offerNumber', 'text' => 'Angebotsnummer', 'required' => true, 'modal' => false],
['key' => 'customerNumber', 'text' => 'Kundennummer', 'required' => true, 'modal' => false],
['key' => 'customerName', 'text' => 'Kundenname', 'required' => true, 'modal' => false],
['key' => 'contactPerson', 'text' => 'Kontaktperson', 'required' => false, 'modal' => false],
['key' => 'customerCity', 'text' => 'Stadt', 'required' => true, 'modal' => false],
['key' => 'customerVAT', 'text' => 'UID', 'required' => false, 'modal' => false],
['key' => 'totalAmount', 'text' => 'Gesamtbetrag', 'required' => true, 'modal' => false, 'table' => ['formatter' => 'formatPrice']],
['key' => 'status', 'text' => 'Status', 'required' => true, 'modal' => ['type' => 'select', 'items' => [
['value' => 'new', 'text' => 'Neu'],
['value' => 'sent', 'text' => 'Ausgeschickt'],
['value' => 'accepted', 'text' => 'Angenommen'],
['value' => 'rejected', 'text' => 'Abgelehnt'],
['value' => 'cancelled', 'text' => 'Storniert'],
]], 'table' => ['filter' => 'select']],
['key' => 'editor', 'text' => 'Sachbearbeiter', 'required' => true, 'modal' => ['type' => 'select'], 'table' => ['filter' => 'select']],
['key' => 'totalAmount', 'text' => 'Gesamtbetrag', 'required' => true, 'modal' => false],
['key' => 'status', 'text' => 'Status', 'required' => true],
['key' => 'create', 'text' => 'Erstellt', 'required' => true, 'modal' => false],
['key' => 'createBy', 'text' => 'Erstellt von', 'required' => true, 'modal' => ['type' => 'select'], 'table' => ['filter' => 'select']],
['key' => 'actions',
'text' => 'Aktionen',
'required' => false,
'modal' => false,
'table' => ['filter' => false, 'sortable' => false, 'class' => 'text-center']],
['key' => 'create', 'text' => 'Erstellt', 'required' => true, 'modal' => false, 'table' => ['formatter' => 'formatDate']],
['key' => 'lastSentDate', 'text' => 'Zuletzt gesendet', 'required' => false, 'modal' => false, 'table' => ['formatter' => 'formatDate']],
['key' => 'actions', 'text' => 'Aktionen', 'required' => false, 'modal' => false, 'table' => ['filter' => false, 'sortable' => false, 'class' => 'text-center']],
];
protected array $additionalJSVariables = ['WAREHOUSE_ADMIN' => true];
protected array $permissionCheck = ['WarehouseAdmin'];
protected array $additionalActions = [['key' => 'openpdf', 'title' => 'PDF öffnen', 'class' => 'fas fa-file-pdf', 'color' => 'primary']];
protected function prepareCrudConfig(): void {
protected array $additionalJSVariables = ['WAREHOUSE_ADMIN' => false];
protected array $permissionCheck = ['WarehouseAdmin', 'WarehouseUser'];
protected array $additionalActions = [
['key' => 'openpdf', 'title' => 'PDF öffnen', 'class' => 'fas fa-file-pdf', 'color' => 'primary'],
['key' => 'sendmail', 'title' => 'Angebot senden', 'class' => 'fas fa-paper-plane', 'color' => 'success'],
];
protected function prepareCrudConfig(): void
{
$editorColumnIndex = array_search('editor', array_column($this->columns, 'key'));
$this->columns[$editorColumnIndex]['modal']['items'] = array_map(function ($user) {
return ['value' => intval($user->id), 'text' => $user->name];
}, UserModel::search(['employee' => true]));
if (!$this->user->can('WarehouseAdmin')) $this->additionalJSVariables['WAREHOUSE_ADMIN'] = false;
if ($this->user->can('WarehouseAdmin')) {
$this->additionalJSVariables['WAREHOUSE_ADMIN'] = true;
}
}
protected function beforeCreate(): bool {
protected function beforeCreate(): bool
{
$currentCount = WarehouseOfferModel::count(['create' => ['from' => strtotime(date('Y-01-01'))]]);
$this->postData['offerNumber'] = 'AN' . date('Y') . '-' . str_pad($currentCount + 1, 4, '0', STR_PAD_LEFT);
$this->postData['status'] = 'new';
$this->postData['version'] = 1;
return true;
}
protected function afterCreate($id): void
{
$offer = WarehouseOfferModel::get($id);
$this->createHistoryEntry($id, 1, $offer);
}
protected function beforeUpdate($postData): bool
{
$oldOffer = WarehouseOfferModel::get($postData['id']);
$newVersion = $oldOffer->version + 1;
// Create history entry before updating the main record
$historyId = $this->createHistoryEntry($postData['id'], $newVersion, $postData);
$this->postData['version'] = $newVersion;
$this->postData['history_id'] = $historyId; // Link to the latest history entry
return true;
}
protected function beforeUpdate($postData): bool {
(new WarehouseHistoryController)->create($postData, $this->mod);
return true;
private function createHistoryEntry($offerId, $version, $data)
{
$historyData = is_object($data) ? json_encode($data) : json_encode((object)$data);
return WarehouseHistoryModel::create([
'table' => $this->mod,
'row_id' => $offerId,
'key' => 'version',
'old_value' => $version - 1,
'new_value' => $version,
'note' => 'Version ' . $version . ' erstellt.',
'data' => $historyData, // Store full data snapshot
'user_id' => $this->user->id,
'create' => time()
]);
}
protected function getHistoryAction() {
self::returnJson((new WarehouseHistoryController)->getHistory($this->request->id, $this->mod, $this->columns));
public function getVersionsAction()
{
$id = $this->request->id;
if (!$id) self::sendError("ID fehlt");
$history = WarehouseHistoryModel::getByRowId($id, $this->mod);
$versions = array_map(function ($entry) {
return [
'id' => $entry->id,
'version' => $entry->new_value,
'user' => $entry->user_name,
'date' => $entry->create,
'data' => json_decode($entry->data)
];
}, $history);
self::returnJson($versions);
}
protected function createTemplateAction() {
if (!$this->user->can('WarehouseAdmin')) self::sendError("Keine Berechtigung");
// Journal Actions
public function getJournalAction()
{
$id = $this->request->id;
if (!$id) self::sendError("ID fehlt");
$journalEntries = WarehouseOfferJournalModel::search(['offerId' => $id], ['create' => 'DESC']);
self::returnJson($journalEntries);
}
public function addJournalEntryAction()
{
$_POST = json_decode(file_get_contents('php://input'), true);
$id = $_POST['id'] ?? null;
$message = $_POST['message'] ?? '';
$fileIds = isset($_POST['fileIds']) ? json_encode($_POST['fileIds']) : null;
$templateId = WarehouseOfferTemplateModel::create([
'templateName' => $_POST['name'],
'positions' => $_POST['positions'],
'totalDiscount' => $_POST['totalDiscount'],
'paymentTerms' => $_POST['paymentTerms'],
'deliveryTerms' => $_POST['deliveryTerms'],
'closingText' => $_POST['closingText'],
'notes' => $_POST['notes'],
if (!$id) self::sendError("ID fehlt");
WarehouseOfferJournalModel::create([
'offerId' => $id,
'message' => $message,
'fileIds' => $fileIds,
'createBy' => $this->user->id,
'create' => time()
]);
self::returnJson(['success' => true, 'id' => $templateId]);
self::returnJson(['success' => true, 'message' => 'Journaleintrag hinzugefügt.']);
}
protected function deleteTemplateAction() {
// Closing Text Actions
public function getClosingTextsAction()
{
self::returnJson(WarehouseOfferClosingTextModel::getAll());
}
public function createClosingTextAction()
{
if (!$this->user->can('WarehouseAdmin')) self::sendError("Keine Berechtigung");
WarehouseOfferTemplateModel::delete($this->request->id);
$_POST = json_decode(file_get_contents('php://input'), true);
$id = WarehouseOfferClosingTextModel::create([
'name' => $_POST['name'],
'text' => $_POST['text'],
'createBy' => $this->user->id,
'create' => time()
]);
self::returnJson(['success' => true, 'id' => $id]);
}
public function updateClosingTextAction()
{
if (!$this->user->can('WarehouseAdmin')) self::sendError("Keine Berechtigung");
$_POST = json_decode(file_get_contents('php://input'), true);
WarehouseOfferClosingTextModel::update($_POST['id'], ['name' => $_POST['name'], 'text' => $_POST['text']]);
self::returnJson(['success' => true]);
}
protected function getTemplatesAction() {
self::returnJson(WarehouseOfferTemplateModel::getAll());
public function deleteClosingTextAction()
{
if (!$this->user->can('WarehouseAdmin')) self::sendError("Keine Berechtigung");
WarehouseOfferClosingTextModel::delete($this->request->id);
self::returnJson(['success' => true]);
}
public function createPDFAction($returnFilename = false, $idOverride = null) {
// Display errors (Keep for debugging, consider removing for production)
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
error_reporting(E_ALL);
$id = $idOverride ?? $this->request->id;
if (empty($id)) {
self::sendError('ID fehlt'); // Use empty() for better checks
// E-Mail Actions
public function sendOfferEmailAction()
{
$_POST = json_decode(file_get_contents('php://input'), true);
$id = $_POST['id'] ?? null;
$recipientEmail = $_POST['email'] ?? null;
$subject = $_POST['subject'] ?? 'Ihr Angebot von XINON GmbH';
$body = $_POST['body'] ?? 'Anbei finden Sie Ihr angefordertes Angebot.';
if (!$id || !$recipientEmail) {
self::sendError("ID oder E-Mail-Adresse fehlt.");
}
$offer = WarehouseOfferModel::get($id);
if (!$offer || !$offer->id) { // Check if offer object is valid
self::sendError('Angebot nicht gefunden');
if (!$offer) {
self::sendError("Angebot nicht gefunden.");
}
// --- Customer Data (Assuming fields exist on $offer) ---
// You might need to fetch a CustomerModel if data isn't directly on the offer
// $customer = CustomerModel::get($offer->customerId);
// $customerName = $customer->name; // Example
$customerName = $offer->customerName ?? 'N/A';
$customerStreet = $offer->customerStreet ?? '';
$customerZip = $offer->customerZip ?? '';
$customerCity = $offer->customerCity ?? '';
$customerCountryId = $offer->customerCountryId ?? null; // Assuming country ID exists
$customerCountry = $customerCountryId ? (new Country($customerCountryId))->name : '';
// Approval Logic
if ($offer->totalAmount > 5000 && !$this->user->can('WarehouseAdmin')) {
// Send approval request to admin
$this->sendApprovalRequestEmail($offer);
self::returnJson(['success' => true, 'message' => 'Angebotssumme über 5000€. Freigabeanfrage wurde an einen Administrator gesendet.']);
return;
}
// Construct address lines (adjust based on your actual data fields)
$addressLines = [];
$addressLines['header'] = "<strong>Empfänger</strong>"; // Or "Kunde"
$addressLines['1'] = $customerName;
$addressLines['2'] = $customerStreet;
$addressLines['3'] = $customerZip . ' ' . $customerCity;
$addressLines['4'] = $customerCountry;
$addressLines['5'] = ''; // Add more lines if needed (e.g., contact person)
// Generate PDF
$pdfContent = $this->createPDFAction(true, $id, true);
// --- Billing Address (Assuming fields exist on $offer, check if different) ---
// Example: Check if specific billing fields exist and are filled
$useBillingAddress = !empty($offer->billingName) || !empty($offer->billingStreet);
if ($useBillingAddress) {
$billingAddressLines = [];
$billingAddressLines['header'] = "<strong>Rechnungsadresse</strong>";
$billingAddressLines['1'] = $offer->billingName ?? $customerName;
$billingAddressLines['2'] = $offer->billingStreet ?? '';
$billingAddressLines['3'] = ($offer->billingZip ?? '') . ' ' . ($offer->billingCity ?? '');
$billingAddressLines['4'] = $offer->billingCountryId ? (new Country($offer->billingCountryId))->name : '';
$billingAddressLines['5'] = '';
$billingAddressLines['6'] = ''; // Add more lines if needed
// Send Email
$mail = new Mail();
$mail->addAddress($recipientEmail, $offer->contactPerson ?? $offer->customerName);
$mail->setSubject($subject);
$mail->setBody($body);
$mail->addStringAttachment($pdfContent, $offer->offerNumber . '_Angebot.pdf', 'base64', 'application/pdf');
if ($mail->send()) {
// Update offer status and last sent date
WarehouseOfferModel::update($id, ['status' => 'sent', 'lastSentDate' => time()]);
// Add Journal Entry
WarehouseOfferJournalModel::create([
'offerId' => $id,
'message' => "Angebot per E-Mail an $recipientEmail gesendet.",
'createBy' => $this->user->id,
'create' => time()
]);
self::returnJson(['success' => true, 'message' => 'E-Mail erfolgreich versendet.']);
} else {
// If no specific billing address, maybe hide the section or repeat customer address
$billingAddressLines = ['header' => '', '1' => '', '2' => '', '3' => '', '4' => '', '5' => '', '6' => '']; // Effectively hides it
// Or repeat customer address:
// $billingAddressLines = $addressLines;
// $billingAddressLines['header'] = "<strong>Rechnungsadresse</strong>";
self::sendError('E-Mail konnte nicht gesendet werden. Fehler: ' . $mail->ErrorInfo);
}
}
private function sendApprovalRequestEmail($offer)
{
$admins = UserModel::search(['permission' => 'WarehouseAdmin']);
$mail = new Mail();
foreach ($admins as $admin) {
$mail->addAddress($admin->email, $admin->name);
}
$mail->setSubject("Angebotsfreigabe erforderlich: " . $offer->offerNumber);
$body = "Hallo,<br><br>das Angebot <strong>{$offer->offerNumber}</strong> für Kunde <strong>{$offer->customerName}</strong> übersteigt die Summe von 5.000,00 € und erfordert Ihre Freigabe.<br><br>";
$body .= "<strong>Gesamtsumme:</strong> " . number_format($offer->totalAmount, 2, ',', '.') . " €<br>";
$body .= "<strong>Erstellt von:</strong> " . (UserModel::getOne($offer->editor))->name . "<br><br>";
$body .= "Bitte prüfen und genehmigen Sie das Angebot im System.<br><br>Danke,<br>Ihr TheTool System";
$mail->setBody($body);
$mail->send();
// Add Journal Entry for approval request
WarehouseOfferJournalModel::create([
'offerId' => $offer->id,
'message' => "Freigabeanfrage für Angebot an Administratoren gesendet.",
'createBy' => $this->user->id,
'create' => time()
]);
}
public function sendReminderEmailsAction()
{
// This action should be triggered by a cronjob daily
$twoWeeksAgo = strtotime('-14 days');
$offersToRemind = WarehouseOfferModel::search([
'status' => 'sent',
'lastSentDate' => ['to' => $twoWeeksAgo]
]);
foreach ($offersToRemind as $offer) {
$recipientEmail = $offer->contactPersonEmail;
if (!$recipientEmail) continue;
$subject = "Erinnerung: Ihr Angebot " . $offer->offerNumber;
$body = "Sehr geehrte/r " . ($offer->contactPerson ?? $offer->customerName) . ",<br><br>";
$body .= "hiermit möchten wir Sie an unser Angebot mit der Nummer <strong>{$offer->offerNumber}</strong> vom " . date('d.m.Y', $offer->create) . " erinnern.<br><br>";
$body .= "Sollten Sie Fragen haben oder eine Anpassung wünschen, stehen wir Ihnen gerne zur Verfügung.<br><br>Mit freundlichen Grüßen,<br>Ihr XINON Team";
$pdfContent = $this->createPDFAction(true, $offer->id, true);
$mail = new Mail();
$mail->addAddress($recipientEmail);
$mail->setSubject($subject);
$mail->setBody($body);
$mail->addStringAttachment($pdfContent, $offer->offerNumber . '_Angebot.pdf', 'base64', 'application/pdf');
if ($mail->send()) {
// Update last sent date to avoid sending reminders every day
WarehouseOfferModel::update($offer->id, ['lastSentDate' => time()]);
WarehouseOfferJournalModel::create([
'offerId' => $offer->id,
'message' => "Automatische Erinnerungs-E-Mail gesendet an $recipientEmail.",
'createBy' => 0, // System user
'create' => time()
]);
}
}
echo "Reminder job finished.";
}
// PDF Generation
public function createPDFAction($returnContent = false, $idOverride = null, $fromEmail = false)
{
$id = $idOverride ?? $this->request->id;
if (empty($id)) self::sendError('ID fehlt');
$version = $this->request->version ?? null;
$offerData = null;
if ($version) {
$historyEntry = WarehouseHistoryModel::getOneByVersion($id, $this->mod, $version);
if ($historyEntry && !empty($historyEntry->data)) {
$offerData = json_decode($historyEntry->data);
}
}
if (!$offerData) {
$offer = WarehouseOfferModel::get($id);
if (!$offer || !$offer->id) self::sendError('Angebot nicht gefunden');
$offerData = $offer;
}
// --- Customer & Billing Address ---
// ... (address logic remains similar to original)
// --- Process Positions ---
$positionsRaw = json_decode($offer->positions, true);
$positionsRaw = is_string($offerData->positions) ? json_decode($offerData->positions, true) : (array)$offerData->positions;
$entries = [];
$subTotal = 0; // Calculate subtotal here
$subTotal = 0;
$alternativeTotal = 0;
if (is_array($positionsRaw)) {
foreach ($positionsRaw as $position) {
if (!isset($position['article']) || empty($position['article'])) continue;
foreach ($positionsRaw as $p) {
$p = (array)$p; // Ensure $p is an array
if (!isset($p['article']) || empty($p['article']) || (isset($p['amount']) && $p['amount'] == 0)) continue;
$article = WarehouseArticleModel::get($position['article']);
if (!$article) continue; // Skip if article not found
$article = WarehouseArticleModel::get($p['article']);
if (!$article) continue;
$position['articleNumber'] = $article->articleNumber ?? null;
$position['articleText'] = $article->title;
$position['articleDescription'] = ($article->description !== $article->title) ? ($article->description ?? '') : '';
$position['articleUnit'] = $position['unit'] ?? $article->unit ?? 'Stk.'; // Default to 'Stk.'
$position['price'] = (float)($position['unitPrice'] ?? 0); // Ensure price is float
$position['amount'] = (float)($position['amount'] ?? 0); // Ensure amount is float
$position['totalPrice'] = $position['unitPrice'] * $position['amount'];
$p['articleNumber'] = $article->articleNumber ?? null;
$p['articleText'] = $article->title;
$p['articleDescription'] = ($article->description !== $article->title) ? ($article->description ?? '') : '';
$p['articleUnit'] = $p['unit'] ?? $article->unit ?? 'Stk.';
$p['price'] = (float)($p['unitPrice'] ?? 0);
$p['amount'] = (float)($p['amount'] ?? 0);
$discount = isset($p['discount']) ? (float)$p['discount'] : 0;
$p['totalPrice'] = ($p['price'] * $p['amount']) * (1 - $discount / 100);
$p['comment'] = $p['comment'] ?? ''; // Add comment field
$subTotal += $position['totalPrice']; // Add to subtotal
$isAlternative = isset($p['isAlternative']) && $p['isAlternative'];
// Grouping logic
$groupKey = $position['_group'] ?? ''; // Use empty string as default group
if (!isset($entries[$groupKey])) {
$entries[$groupKey] = []; // Initialize group if not exists
if (!$isAlternative) {
$subTotal += $p['totalPrice'];
} else {
$alternativeTotal += $p['totalPrice'];
}
$entries[$groupKey][] = $position;
$groupKey = $isAlternative ? 'Alternativpositionen' : ($p['_group'] ?? '');
if (!isset($entries[$groupKey])) $entries[$groupKey] = [];
$entries[$groupKey][] = $p;
}
} else {
// Handle case where positions JSON is invalid or empty
trigger_error("Invalid or empty positions JSON for offer ID: " . $id, E_USER_WARNING);
}
$editor = UserModel::getOne($offer->editor);
$editorName = $editor ? $editor->name : 'Unbekannt'; // Fallback if editor not found
$editor = UserModel::getOne($offerData->editor);
// --- Prepare PDF Variables ---
$pdf_vars = [
"offer" => $offer,
"entries" => $entries, // Grouped entries
"offer" => $offerData,
"entries" => $entries,
"subTotal" => $subTotal,
// Add other offer details needed in PDF_MAIN
"offerNumber" => $offer->offerNumber ?? $offer->id, // Use offerNumber if available
"offerDate" => $offer->createDate ?? time(), // Assuming createDate is a timestamp
"offerEditorName" => $editorName, // Editor name for the offer
"validUntilDate" => $offer->validUntilDate ?? null, // Assuming validUntilDate is a timestamp
"includeTax" => $offer->includeTax ?? true, // Default to including tax (e.g., 20% VAT)
"vatRate" => 0.20, // Example VAT rate (20%) - make this configurable if needed
"offerText" => $offer->offerText ?? '', // Optional text block from the offer
"alternativeTotal" => $alternativeTotal,
"offerNumber" => $offerData->offerNumber,
"offerDate" => $offerData->create,
"offerEditorName" => $editor ? $editor->name : 'Unbekannt',
"includeTax" => true,
"vatRate" => 0.20,
"offerText" => $offerData->notes ?? '',
"closingText" => $offerData->closingText ?? '',
"bank_iban" => TT_INVOICE_BANK_IBAN,
"bank_bic" => TT_INVOICE_BANK_BIC,
"bank_bank" => TT_INVOICE_BANK_BANK,
"bank_owner" => TT_INVOICE_BANK_OWNER
// Add any other variables needed in PDF_MAIN.php
];
// --- Prepare Replacements for Header/Footer ---
// --- Customer & Billing Address ---
// Since the offer model doesn't have a separate billing address,
// we'll populate the main address and provide empty values for the billing address section.
$addressLines = [
'header' => "<strong>Empfänger</strong>",
'1' => $offerData->customerName ?? '',
'2' => $offerData->customerStreet ?? '',
'3' => ($offerData->customerZip ?? '') . ' ' . ($offerData->customerCity ?? ''),
'4' => '', // Placeholder for country if needed in the future
'5' => !empty($offerData->contactPerson) ? 'z.H. ' . htmlspecialchars($offerData->contactPerson) : ''
];
// Provide empty values for the billing address section in the template to avoid errors
$billingAddressLines = [
'header' => '', '1' => '', '2' => '', '3' => '', '4' => '', '5' => '', '6' => ''
];
// --- Prepare Replacements for Header/Footer ---
$replacements = [
'basedir' => BASEDIR, // Crucial for images/assets in header/footer
'externalReference' => !empty($offer->extReference) ?
"<strong>Ihre Referenz:</strong> " . htmlspecialchars($offer->extReference) : "", // Added htmlspecialchars
'basedir' => BASEDIR,
'externalReference' => !empty($offerData->reference) ?
"<strong>Ihre Referenz:</strong> " . htmlspecialchars($offerData->reference) : "",
// Customer Address
'addressLine_header' => $addressLines['header'],
'addressLine_1' => htmlspecialchars($addressLines['1']),
'addressLine_2' => htmlspecialchars($addressLines['2']),
'addressLine_3' => htmlspecialchars($addressLines['3']),
'addressLine_4' => htmlspecialchars($addressLines['4']),
'addressLine_5' => htmlspecialchars($addressLines['5']),
// Customer Address (Empfänger)
'addressLine_header' => $addressLines['header'],
'addressLine_1' => htmlspecialchars($addressLines['1']),
'addressLine_2' => htmlspecialchars($addressLines['2']),
'addressLine_3' => htmlspecialchars($addressLines['3']),
'addressLine_4' => htmlspecialchars($addressLines['4']),
'addressLine_5' => $addressLines['5'], // Already escaped
// Billing Address
// Billing Address (Rechnungsadresse) - left blank as per model
'billingAddressLine_header' => $billingAddressLines['header'],
'billingAddressLine_1' => htmlspecialchars($billingAddressLines['1']),
'billingAddressLine_2' => htmlspecialchars($billingAddressLines['2']),
'billingAddressLine_3' => htmlspecialchars($billingAddressLines['3']),
'billingAddressLine_4' => htmlspecialchars($billingAddressLines['4']),
'billingAddressLine_5' => htmlspecialchars($billingAddressLines['5']),
'billingAddressLine_6' => htmlspecialchars($billingAddressLines['6']),
'billingAddressLine_1' => htmlspecialchars($billingAddressLines['1']),
'billingAddressLine_2' => htmlspecialchars($billingAddressLines['2']),
'billingAddressLine_3' => htmlspecialchars($billingAddressLines['3']),
'billingAddressLine_4' => htmlspecialchars($billingAddressLines['4']),
'billingAddressLine_5' => htmlspecialchars($billingAddressLines['5']),
'billingAddressLine_6' => htmlspecialchars($billingAddressLines['6']),
// Footer variables (assuming PDF_FOOTER.HTML needs these)
'bank_iban' => defined('TT_INVOICE_BANK_IBAN_FORMATTED') ? TT_INVOICE_BANK_IBAN_FORMATTED : TT_INVOICE_BANK_IBAN,
'bank_bic' => TT_INVOICE_BANK_BIC,
'bank_bank' => TT_INVOICE_BANK_BANK,
'bank_owner' => TT_INVOICE_BANK_OWNER,
// Add other company info if needed in footer (e.g., VAT ID, address)
'company_vat_id' => 'ATU68711968', // Example
'company_address' => 'Fladnitz im Raabtal 150, 8322 Studenzen', // Example
'company_phone' => '+43 1 2345678', // Example
'company_email' => 'office@xinon.at' // Example
// Footer variables with fallbacks
'bank_iban' => defined('TT_INVOICE_BANK_IBAN_FORMATTED') ? TT_INVOICE_BANK_IBAN_FORMATTED : (defined('TT_INVOICE_BANK_IBAN') ? TT_INVOICE_BANK_IBAN : ''),
'bank_bic' => defined('TT_INVOICE_BANK_BIC') ? TT_INVOICE_BANK_BIC : '',
'bank_bank' => defined('TT_INVOICE_BANK_BANK') ? TT_INVOICE_BANK_BANK : '',
'bank_owner' => defined('TT_INVOICE_BANK_OWNER') ? TT_INVOICE_BANK_OWNER : '',
];
// --- Generate Header/Footer Temp Files ---
// Ensure the temp directory exists and is writable
$tempDir = BASEDIR . "/var/temp";
if (!is_dir($tempDir)) {
mkdir($tempDir, 0775, true);
}
$headerFile = $tempDir . "/offer_header-" . date("U") . "-" . rand(1000, 9999) . ".html";
$footerFile = $tempDir . "/offer_footer-" . date("U") . "-" . rand(1000, 9999) . ".html";
// Assume generateTemplate exists and works like in the Order example
// You need 'WarehouseOffer/PDF_HEADER' and 'WarehouseOffer/PDF_FOOTER' templates
// Using the provided 'PDF_HEADER.HTML' content directly as the template source path
// **Important:** Adjust 'WarehouseOffer/PDF_HEADER' and 'WarehouseOffer/PDF_FOOTER'
// to match the actual paths or keys your `generateTemplate` function expects.
// If generateTemplate just takes file paths, you might need to save the HTML content
// from the prompt into actual files first (e.g., `views/WarehouseOffer/PDF_HEADER.HTML`).
// Check if template generation succeeds
if (file_put_contents($headerFile, $this->generateTemplate('WarehouseOffer/PDF_HEADER', $replacements)) === false) {
self::sendError('Fehler beim Erstellen der Header-Datei.');
}
if (file_put_contents($footerFile, $this->generateTemplate('WarehouseOffer/PDF_FOOTER', $replacements)) === false) {
// Attempt cleanup before erroring
if (file_exists($headerFile)) unlink($headerFile);
self::sendError('Fehler beim Erstellen der Footer-Datei.');
}
// --- Generate PDF ---
try {
// Use the correct path for your PDF_MAIN template
$pdf = new PdfForm("WarehouseOffer/PDF_MAIN", $pdf_vars);
$tempDir = BASEDIR . "/var/temp";
if (!is_dir($tempDir)) mkdir($tempDir, 0775, true);
// Construct options for wkhtmltopdf
// Adjust margins as needed (T=Top, R=Right, B=Bottom, L=Left)
$options = "--header-html {$headerFile} --footer-html {$footerFile}";
$headerFile = tempnam($tempDir, 'offer_header') . '.html';
$footerFile = tempnam($tempDir, 'offer_footer') . '.html';
$filename = $pdf->render($options); // Pass options to render
file_put_contents($headerFile, $this->generateTemplate('WarehouseOffer/PDF_HEADER', $replacements));
file_put_contents($footerFile, $this->generateTemplate('WarehouseOffer/PDF_FOOTER', $replacements));
} catch (\Exception $e) {
// Log the error for debugging
error_log("PDF Generation Error: " . $e->getMessage());
// Attempt cleanup before erroring
if (file_exists($headerFile)) unlink($headerFile);
if (file_exists($footerFile)) unlink($footerFile);
self::sendError('Fehler beim Erstellen des PDFs: ' . $e->getMessage());
exit; // Ensure script stops
$pdf = new PdfForm("WarehouseOffer/PDF_MAIN", $pdf_vars);
$options = "--header-html {$headerFile} --footer-html {$footerFile}";// --margin-top 45 --margin-bottom 25";
$filename = $pdf->render($options);
// unlink($headerFile);
// unlink($footerFile);
if ($returnContent === true) {
$content = file_get_contents($filename);
// unlink($filename);
return $content;
}
// --- Clean up temporary files ---
// if (file_exists($headerFile)) unlink($headerFile);
// if (file_exists($footerFile)) unlink($footerFile);
if (!file_exists($filename)) self::sendError('Generierte PDF-Datei nicht gefunden.');
// --- Output or Return Filename ---
if ($returnFilename === true) {
return $filename;
}
if (!file_exists($filename)) {
self::sendError('Generierte PDF-Datei nicht gefunden.');
}
// Send PDF to browser
header('Content-Type: application/pdf');
// Use offer number in filename if available
$outputFilename = ($offer->offerNumber ?? $offer->id) . ".pdf";
$outputFilename = ($offerData->offerNumber ?? $offerData->id) . "_v" . ($version ?? $offerData->version) . ".pdf";
header('Content-Disposition: inline; filename="' . basename($outputFilename) . '"');
header('Content-Length: ' . filesize($filename)); // Good practice
header('Content-Length: ' . filesize($filename));
readfile($filename);
// Optionally delete the generated PDF after sending if it's temporary
// unlink($filename);
exit; // Crucial to prevent further output
exit;
}
protected function generateTemplate(string $templateName, array $replacements): string
{
// Example Implementation (Very Basic - Adapt!)
// Assumes templates are in a specific directory and use {{ key }} placeholders
$templatePath = BASEDIR . "/Layout/default/" . $templateName . ".html"; // Adjust path as needed
$templatePath = BASEDIR . "/Layout/default/" . $templateName . ".html";
if (!file_exists($templatePath)) {
self::sendError('Template nicht gefunden: ' . $templatePath);
} else {
$content = file_get_contents($templatePath);
// Fallback to a default or error
$templatePath = BASEDIR . "/modules/" . $templateName . ".html";
if (!file_exists($templatePath)) {
self::sendError('Template nicht gefunden: ' . $templateName);
}
}
$content = file_get_contents($templatePath);
foreach ($replacements as $key => $value) {
$content = str_replace('{{ ' . $key . ' }}', $value ?? '', $content);
}
return $content;
}
protected function getTemplatesAction() {
self::returnJson(WarehouseOfferTemplateModel::getAll());
}
}

View File

@@ -2,11 +2,14 @@
class WarehouseOfferModel extends TTCrudBaseModel {
public int $id;
public int $version;
public ?int $history_id;
public string $offerNumber;
public string $reference;
public string $customerNumber;
public string $customerName;
public ?string $contactPerson;
public ?string $contactPersonEmail; // New field
public string $customerStreet;
public string $customerCity;
public string $customerZip;
@@ -21,7 +24,120 @@ class WarehouseOfferModel extends TTCrudBaseModel {
public string $closingText;
public string $notes;
public string $status;
public ?int $lastSentDate; // New field
public float $totalAmount;
public int $create;
public int $createBy;
}
class WarehouseOfferClosingTextModel extends TTCrudBaseModel {
public int $id;
public string $name;
public string $text;
public int $createBy;
public int $create;
}
/**
* Model for managing journal entries related to a specific warehouse offer.
* It logs messages, file uploads, and status changes for an offer.
*/
class WarehouseOfferJournalModel extends TTCrudBaseModel
{
/**
* @var int The unique identifier for the journal entry.
*/
public int $id;
/**
* @var int The ID of the associated warehouse offer.
*/
public ?int $offerId;
/**
* @var string|null A JSON-encoded array of file IDs associated with this entry.
*/
public ?string $fileIds;
/**
* @var string|null The message or note for this journal entry.
*/
public ?string $message;
/**
* @var int|null The Unix timestamp when the entry was created.
*/
public ?int $create;
/**
* @var int|null The ID of the user who created the entry.
*/
public ?int $createBy;
/**
* @var string|null The name of the user who created the entry (not in DB, joined in search).
*/
public ?string $createByName;
/**
* Custom search method to include the creator's name in the results.
* This method is necessary because a JOIN with the Worker table is required,
* which is not supported by the generic getAll() method in the base model.
*
* @param array $filter
* @param array $orderBy
* @param null $limit
* @param null $offset
* @param false $count
* @return array|int
*/
public static function search(array $filter = [], array $orderBy = [], $limit = null, $offset = null, $count = false)
{
$db = self::getDB();
$tableName = self::getFullyQualifiedTable();
if ($count) {
$sql = "SELECT COUNT(*) as `count` FROM $tableName";
} else {
// Join with the Worker table to get the creator's name
$sql = "SELECT T.*, W.name as createByName FROM $tableName T LEFT JOIN `Worker` W ON T.createBy = W.id";
}
if (!empty($filter)) {
// getSQLFilter from the base model returns the complete WHERE clause.
// The columns in the filter are checked against the model's properties, so this is safe.
// The generated SQL (e.g., WHERE `offerId` = 123) will work because the column name is not ambiguous.
$sql .= " " . self::getSQLFilter($filter);
}
if (!$count && !empty($orderBy)) {
$order = [];
foreach ($orderBy as $key => $value) {
// We prefix the column with the table alias 'T' to avoid ambiguity in JOINs.
$order[] = "T.`$key` " . $db->real_escape_string($value);
}
$sql .= " ORDER BY " . implode(", ", $order);
}
if (!$count && $limit) {
$sql .= " LIMIT " . (int)$limit;
}
if (!$count && $offset) {
$sql .= " OFFSET " . (int)$offset;
}
$result = $db->query($sql);
if ($count) {
return $result->fetch_assoc()['count'];
}
$data = [];
while ($row = $result->fetch_assoc()) {
$data[] = new self($row);
}
return $data;
}
}

View File

@@ -0,0 +1,117 @@
<?php /** @noinspection ALL */
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class WarehouseOfferVersioning extends AbstractMigration
{
public function up(): void
{
$this->execute("
CREATE TABLE IF NOT EXISTS `WarehouseOfferClosingText` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL,
`text` text COLLATE utf8mb4_unicode_ci NOT NULL,
`createBy` int(11) NOT NULL,
`create` int(11) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
");
$this->execute("
CREATE TABLE IF NOT EXISTS `WarehouseOfferJournal` (
`id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT,
`offerId` int(11) DEFAULT NULL,
`fileIds` text COLLATE utf8mb4_unicode_ci,
`message` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`create` int(11) DEFAULT NULL,
`createBy` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `offerId` (`offerId`),
KEY `createBy` (`createBy`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
");
$this->execute("
ALTER TABLE `WarehouseOffer`
ADD COLUMN IF NOT EXISTS `contactPersonEmail` VARCHAR(255) NULL DEFAULT NULL AFTER `contactPerson`,
ADD COLUMN IF NOT EXISTS `lastSentDate` INT(11) NULL DEFAULT NULL AFTER `status`,
ADD COLUMN IF NOT EXISTS `version` INT(11) NOT NULL DEFAULT 1 AFTER `id`,
ADD COLUMN IF NOT EXISTS `history_id` INT(11) NULL DEFAULT NULL AFTER `version`;
");
$this->execute("
ALTER TABLE `WarehouseHistory`
ADD COLUMN IF NOT EXISTS `data` LONGTEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL AFTER `note`;
");
$this->execute("DELETE FROM `WarehouseHistory` WHERE `table` = 'WarehouseOffer';");
$this->execute("
INSERT INTO `WarehouseHistory` (`table`, `row_id`, `key`, `old_value`, `new_value`, `note`, `data`, `user_id`, `create`)
SELECT
'WarehouseOffer' AS `table`,
wo.id AS `row_id`,
'version' AS `key`,
0 AS `old_value`,
1 AS `new_value`,
'Baseline Version 1 erstellt durch Migration.' AS `note`,
JSON_OBJECT(
'id', wo.id,
'version', wo.version,
'history_id', wo.history_id,
'offerNumber', wo.offerNumber,
'reference', wo.reference,
'customerNumber', wo.customerNumber,
'customerName', wo.customerName,
'contactPerson', wo.contactPerson,
'contactPersonEmail', wo.contactPersonEmail,
'customerStreet', wo.customerStreet,
'customerCity', wo.customerCity,
'customerZip', wo.customerZip,
'customerVAT', wo.customerVAT,
'editor', wo.editor,
'purpose', wo.purpose,
'positions', wo.positions,
'alternativePositions', wo.alternativePositions,
'totalDiscount', wo.totalDiscount,
'paymentTerms', wo.paymentTerms,
'deliveryTerms', wo.deliveryTerms,
'closingText', wo.closingText,
'notes', wo.notes,
'status', wo.status,
'lastSentDate', wo.lastSentDate,
'totalAmount', wo.totalAmount,
'create', wo.create,
'createBy', wo.createBy
) AS `data`,
wo.createBy AS `user_id`,
UNIX_TIMESTAMP() AS `create`
FROM `WarehouseOffer` wo;
");
$this->execute("
UPDATE `WarehouseOffer` wo
JOIN `WarehouseHistory` wh ON wo.id = wh.row_id
SET wo.history_id = wh.id
WHERE wh.`table` = 'WarehouseOffer' AND wh.new_value = 1 AND wh.note = 'Baseline Version 1 erstellt durch Migration.';
");
}
public function down(): void
{
$this->execute("DELETE FROM `WarehouseHistory` WHERE `note` = 'Baseline Version 1 erstellt durch Migration.' AND `table` = 'WarehouseOffer';");
$warehouseOfferTable = $this->table('WarehouseOffer');
if ($warehouseOfferTable->hasColumn('history_id')) $warehouseOfferTable->removeColumn('history_id')->save();
if ($warehouseOfferTable->hasColumn('version')) $warehouseOfferTable->removeColumn('version')->save();
if ($warehouseOfferTable->hasColumn('lastSentDate')) $warehouseOfferTable->removeColumn('lastSentDate')->save();
if ($warehouseOfferTable->hasColumn('contactPersonEmail')) $warehouseOfferTable->removeColumn('contactPersonEmail')->save();
$warehouseHistoryTable = $this->table('WarehouseHistory');
if ($warehouseHistoryTable->hasColumn('data')) $warehouseHistoryTable->removeColumn('data')->save();
if ($this->hasTable('WarehouseOfferJournal')) $this->table('WarehouseOfferJournal')->drop()->save();
if ($this->hasTable('WarehouseOfferClosingText')) $this->table('WarehouseOfferClosingText')->drop()->save();
}
}

View File

@@ -1,5 +1,182 @@
@media (min-width: 992px) {
.modal-lg, .modal-xl {
max-width: min(90vw) !important;
max-width: min(90vw, 1400px) !important;
}
}
/* Styles for the expanded row container */
.offer-detail-container {
padding: 1.25rem;
background-color: #f8f9fa;
}
.offer-detail-grid {
display: grid;
grid-template-columns: repeat(1, 1fr);
gap: 1.5rem;
}
@media (min-width: 1024px) {
.offer-detail-grid {
grid-template-columns: repeat(3, 1fr);
}
}
.offer-info-pane, .offer-journal-pane {
background-color: #ffffff;
border-radius: 0.5rem;
padding: 1rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
@media (min-width: 1024px) {
.offer-journal-pane {
grid-column: span 2 / span 2;
}
}
.pane-title {
font-weight: 600;
font-size: 1.1rem;
margin-bottom: 1rem;
border-bottom: 1px solid #e9ecef;
padding-bottom: 0.75rem;
color: #343a40;
}
.info-grid {
display: grid;
grid-template-columns: 1fr;
gap: 0.75rem;
}
.info-item {
display: flex;
flex-direction: column;
}
.info-item label {
font-size: 0.75rem;
font-weight: 600;
color: #6c757d;
margin-bottom: 0.1rem;
}
.info-item span {
font-size: 0.9rem;
color: #212529;
}
.info-item .badge {
align-self: flex-start;
font-size: 0.8rem;
padding: .3em .6em;
}
/* Journal Styles */
.journal-box {
max-height: 300px;
overflow-y: auto;
padding-right: 10px;
}
.journal-entry-styled {
display: flex;
align-items: flex-start;
gap: 0.75rem;
}
.journal-entry-styled:not(:last-child) {
margin-bottom: 1.25rem;
}
.journal-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
flex-shrink: 0;
margin-top: 2px;
}
.journal-content {
flex-grow: 1;
}
.journal-header {
display: flex;
align-items: baseline;
margin-bottom: 0.25rem;
}
.journal-message {
margin: 0;
font-size: 0.9rem;
color: #495057;
white-space: pre-wrap;
}
.new-journal-entry {
border-top: 1px solid #e9ecef;
padding-top: 1rem;
margin-top: 1rem;
}
.new-journal-actions {
display: flex;
justify-content: flex-end;
align-items: center;
margin-top: 0.5rem;
gap: 0.5rem;
}
/* Styles for tt-file-upload-light */
.tt-file-upload-light .file-list {
border: 1px solid #e3e3e3;
border-radius: .25rem;
padding: .5rem;
}
.tt-file-upload-light .file-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: .25rem .5rem;
font-size: 0.9em;
}
.tt-file-upload-light .file-item:not(:last-child) {
border-bottom: 1px solid #f0f0f0;
}
.tt-file-upload-light .file-info {
display: flex;
align-items: center;
}
.tt-file-upload-light .file-icon {
margin-right: 8px;
width: 16px; /* for alignment */
text-align: center;
}
.tt-file-upload-light .file-name {
max-width: 250px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tt-file-upload-light .file-size {
color: #6c757d;
margin-left: 8px;
}
.tt-file-upload-light .file-status {
display: flex;
align-items: center;
}

View File

@@ -1,5 +1,188 @@
// noinspection JSUnresolvedReference
Vue.component('tt-file-upload-light', {
props: {
/**
* Allows multiple files to be selected.
*/
multiple: {
type: Boolean,
default: false
},
/**
* Text for the upload button.
*/
buttonText: {
type: String,
default: 'Datei hochladen'
},
/**
* Optional icon for the upload button.
*/
buttonIcon: {
type: String,
default: 'fas fa-paperclip'
}
},
template: `
<div class="tt-file-upload-light">
<!-- Hidden file input, triggered by the button -->
<input type="file" :multiple="multiple" @change="handleFileSelect" ref="fileInput" style="display: none;"/>
<!-- The button that users click to select files -->
<button type="button" class="btn btn-outline-secondary btn-sm" @click="triggerFileInput" :disabled="isUploading">
<i :class="buttonIcon"></i>
<span v-if="buttonText" class="ml-1">{{ buttonText }}</span>
</button>
<!-- List of files being uploaded or already uploaded -->
<div v-if="files.length > 0" class="file-list mt-2">
<div v-for="(file, index) in files" :key="index" class="file-item">
<div class="file-info">
<i :class="fileIcon(file)" class="file-icon"></i>
<span class="file-name">{{ file.file.name }}</span>
<span class="file-size">({{ formatSize(file.file.size) }})</span>
</div>
<div class="file-status">
<!-- Progress bar during upload -->
<div v-if="file.status === 'uploading'" class="progress" style="height: 10px; width: 100px;">
<div class="progress-bar" role="progressbar" :style="{ width: file.progress + '%' }" aria-valuenow="file.progress" aria-valuemin="0" aria-valuemax="100"></div>
</div>
<!-- Status text -->
<span v-if="file.status === 'error'" class="text-danger small">Fehler</span>
<span v-if="file.status === 'success'" class="text-success small">Fertig</span>
<!-- Remove button -->
<button type="button" class="btn btn-link btn-sm text-danger p-0 ml-2" @click="removeFile(index)">
<i class="fas fa-times"></i>
</button>
</div>
</div>
</div>
</div>
`,
data() {
return {
files: [], // Array to store file wrappers { file, progress, status, response }
uploadUrl: window.TT_CONFIG['BASE_PATH'] + '/File/upload', // Standard file upload endpoint
}
},
computed: {
/**
* Checks if any file is currently being uploaded.
* @returns {boolean}
*/
isUploading() {
return this.files.some(f => f.status === 'uploading');
}
},
methods: {
/**
* Programmatically clicks the hidden file input element.
*/
triggerFileInput() {
this.$refs.fileInput.click();
},
/**
* Handles the file selection event when the user chooses files.
* @param {Event} event - The file input change event.
*/
handleFileSelect(event) {
const selectedFiles = event.target.files;
if (!selectedFiles) return;
// Create a wrapper for each file and start the upload
Array.from(selectedFiles).forEach(file => {
const fileWrapper = {
file: file,
progress: 0,
status: 'pending', // Status: pending, uploading, success, error
response: null,
};
this.files.push(fileWrapper);
this.uploadFile(fileWrapper);
});
// Reset the input value to allow selecting the same file again
event.target.value = '';
},
/**
* Uploads a single file to the server.
* @param {object} fileWrapper - The file object wrapper.
*/
async uploadFile(fileWrapper) {
const formData = new FormData();
formData.append('file', fileWrapper.file);
fileWrapper.status = 'uploading';
try {
const response = await axios.post(this.uploadUrl, formData, {
onUploadProgress: (progressEvent) => {
fileWrapper.progress = Math.round((progressEvent.loaded * 100) / progressEvent.total);
}
});
fileWrapper.status = 'success';
fileWrapper.response = response.data;
// Emit an event with the successful upload data
this.$emit('uploaded', response.data);
window.notify('success', `${fileWrapper.file.name} erfolgreich hochgeladen.`);
} catch (error) {
fileWrapper.status = 'error';
console.error('File upload failed:', error);
window.notify('error', `Upload von ${fileWrapper.file.name} fehlgeschlagen.`);
}
},
/**
* Public method to reset the component state, clearing all files.
*/
reset() {
this.files = [];
},
/**
* Removes a file from the list.
* @param {number} index - The index of the file to remove.
*/
removeFile(index) {
this.files.splice(index, 1);
},
/**
* Formats file size from bytes to a readable string.
* @param {number} bytes - The file size in bytes.
* @returns {string}
*/
formatSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
},
/**
* Returns a Font Awesome icon class based on the file status.
* @param {object} file - The file wrapper object.
* @returns {string}
*/
fileIcon(file) {
switch (file.status) {
case 'uploading': return 'fas fa-spinner fa-spin';
case 'success': return 'fas fa-check-circle text-success';
case 'error': return 'fas fa-exclamation-circle text-danger';
default: return 'fas fa-file-alt';
}
}
}
});
Vue.component('warehouse-offer-create-basic-offer-modal', {
props: {
show: {type: Boolean, default: false}
@@ -575,53 +758,105 @@ Vue.component('warehouse-offer-create-basic-offer-modal', {
}
});
// noinspection JSUnresolvedReference,DuplicatedCode
Vue.component('warehouse-offer-modal', {
props: {
id: {type: [String, Number], required: true},
mode: {type: String, default: 'edit'}
},
template: `
<tt-modal :show="true"
@submit="submit"
:delete="id !== 'create'"
:title="id === 'create' ? 'Angebot erstellen' : \`Angebot #\${id} bearbeiten\`"
@update:show="$emit('close')">
<div style="width: 99%"><h4 class="text-center">Angebotsdetails</h4>
<tt-select label="Sachbearbeiter"
:options="window.TT_CONFIG.CRUD_CONFIG.columns.find(column => column.key === 'createBy')?.modal.items"
sm
row
v-model="offer.editor"/>
<tt-autocomplete label="Kunde" v-model="offer.customerNumber" sm row :api-url="billAddrAutoCompleteUrl"/>
<tt-input label="Kundenreferenz" v-model="offer.reference" sm row/>
<tt-textarea label="Angebotszweck" v-model="offer.purpose" sm row/>
<hr>
<h4 class="text-center">Kundenadresse</h4>
<div style="display: grid; grid-gap: 10px; grid-template-columns: 2fr 1fr 2fr 1fr 1fr 1fr;">
<tt-input label="Name" v-model="offer.customerName" sm/>
<tt-input label="Kontakt" v-model="offer.contactPerson" sm/>
<tt-input label="Straße" v-model="offer.customerStreet" sm/>
<tt-input label="PLZ" v-model="offer.customerZip" sm/>
<tt-input label="Ort" v-model="offer.customerCity" sm/>
<tt-input label="USt-IdNr." v-model="offer.customerVAT" sm/>
:title="modalTitle"
@update:show="$emit('close')"
size="xl">
<div style="width: 99%">
<!-- Header with Version Dropdown -->
<h4 class="text-center mb-0">Angebotsdetails</h4>
<div v-if="id !== 'create' && versions.length > 0" class="d-flex align-items-center">
<label for="version-select" class="mr-2 mb-0">Version:</label>
<select id="version-select" class="form-control form-control-sm mr-2" v-model="selectedVersion" @change="loadVersion" style="max-width:300px">
<option v-for="v in versions" :value="v.version">{{ v.version }} ({{ formatDate(v.date) }} - {{ v.user }})</option>
</select>
<tt-button text="PDF für diese Version" @click="openVersionPDF" sm icon="fas fa-file-pdf" additional-class="btn-primary"/>
</div>
<!-- Customer and Contact -->
<div class="card mb-3">
<div class="card-header">Kunde & Kontakt</div>
<div class="card-body">
<div class="row">
<div class="col-md-4">
<tt-autocomplete label="Kunde" v-model="offer.customerNumber" sm :api-url="billAddrAutoCompleteUrl" :disabled="isReadonly"/>
</div>
<div class="col-md-4">
<tt-input label="Kontaktperson" v-model="offer.contactPerson" sm :disabled="isReadonly"/>
</div>
<div class="col-md-4">
<tt-input label="Kontakt E-Mail" v-model="offer.contactPersonEmail" sm :disabled="isReadonly"/>
</div>
</div>
<tt-input label="Kundenreferenz" v-model="offer.reference" sm row :disabled="isReadonly"/>
<tt-textarea label="Angebotszweck" v-model="offer.purpose" sm row :disabled="isReadonly"/>
</div>
</div>
<!-- Customer Address -->
<div class="card mb-3">
<div class="card-header">Kundenadresse</div>
<div class="card-body">
<div style="display: grid; grid-gap: 10px; grid-template-columns: 2fr 1fr 2fr 1fr 1fr 1fr;">
<tt-input label="Name" v-model="offer.customerName" sm :disabled="isReadonly"/>
<tt-input label="Straße" v-model="offer.customerStreet" sm :disabled="isReadonly"/>
<tt-input label="PLZ" v-model="offer.customerZip" sm :disabled="isReadonly"/>
<tt-input label="Ort" v-model="offer.customerCity" sm :disabled="isReadonly"/>
<tt-input label="USt-IdNr." v-model="offer.customerVAT" sm :disabled="isReadonly"/>
</div>
</div>
</div>
<!-- Positions -->
<div class="card mb-3">
<div class="card-header">Positionen</div>
<div class="card-body" ref="positionsManagerContainer">
<tt-positions-manager group-mode ref="positionsManager" v-model="offer.positions" :config="positionsConfig"
@updateField-article="fetchArticleData" :readonly="isReadonly"/>
</div>
</div>
<!-- Summary and Terms -->
<div class="card mb-3">
<div class="card-header">Konditionen & Schlusstext</div>
<div class="card-body">
<div class="row">
<div class="col-md-8">
<tt-input label="Gesamtrabatt (%)" v-model.number="offer.totalDiscount" sm row type="number" :disabled="isReadonly"/>
<tt-select label="Zahlungskonditionen" :options="paymentTerms" sm row v-model="offer.paymentTerms" :disabled="isReadonly"/>
<tt-select label="Lieferkonditionen" :options="deliveryTerms" sm row v-model="offer.deliveryTerms" :disabled="isReadonly"/>
</div>
<div class="col-md-4">
<h5 class="text-right">Nettobetrag: {{ formatPrice(netTotalPrice) }} €</h5>
<h5 class="text-right">Alternativbetrag: {{ formatPrice(alternativeTotalPrice) }} €</h5>
<h4 class="text-right">Gesamtbetrag: {{ formatPrice(offerTotalPrice) }} €</h4>
</div>
</div>
<hr>
<div class="d-flex justify-content-between align-items-center">
<label>Schlusstext</label>
<tt-button v-if="!isReadonly" text="Vorlage wählen" @click="showClosingTextModal = true" sm/>
</div>
<tt-textarea sm rows="8" row v-model="offer.closingText" :disabled="isReadonly"/>
<hr>
<tt-textarea label="Interne Notizen" v-model="offer.notes" sm row :disabled="isReadonly"/>
</div>
</div>
<hr>
<h4 class="text-center">Positionen</h4>
<tt-positions-manager group-mode ref="positionsManager" v-model="offer.positions" :config="positionsConfig"
@updateField-article="fetchArticleData"/>
<hr>
<tt-input label="Gesamtrabatt (%)" v-model="offer.totalDiscount" sm row type="number"/>
<tt-input label="Gesamtsumme" v-model="offerTotalPrice" sm row type="number" disabled/>
<tt-select label="Zahlungskonditionen" :options="paymentTerms" sm row v-model="offer.paymentTerms"/>
<tt-select label="Lieferkonditionen" :options="deliveryTerms" sm row v-model="offer.deliveryTerms"/>
<tt-textarea label="Schlusstext" sm rows="11" row v-model="offer.closingText"/>
<hr>
<tt-textarea label="Notizen" v-model="offer.notes" sm row/>
</div>
<!-- Modals -->
<closing-text-modal v-if="showClosingTextModal" @close="showClosingTextModal = false" @select="applyClosingText"/>
<template v-slot:footer-prepend v-if="window.TT_CONFIG['WAREHOUSE_ADMIN'] === '1'">
<template v-slot:footer-prepend v-if="!isReadonly">
<tt-input placeholder="Vorlagenname" no-form-group v-model="templateName"/>
<tt-button text="Als Vorlage speichern" @click="saveTemplate" icon="fas fa-save" additional-class="btn-success"/>
</template>
@@ -629,32 +864,25 @@ Vue.component('warehouse-offer-modal', {
`,
data() {
return {
billAddrAutoCompleteUrl: window.TT_CONFIG['BASE_PATH'] + '/Address/Api?do=findAddress&fibu_primary_account=1',
billAddrAutoCompleteUrl: window.TT_CONFIG['BASE_PATH'] + '/Address/api?do=findAddress&fibu_primary_account=1',
window: window,
isAddressManuallyChanged: false,
versions: [],
selectedVersion: null,
isReadonly: false,
showClosingTextModal: false,
positionsConfig: {
fields: {
article: {
type: 'input-article',
label: 'Artikel',
customFieldReference: 'WarehouseArticle',
},
amount: {type: 'input', label: 'Menge', inputType: 'number', editableInTable: true},
unit: {type: 'input', label: 'Einheit'},
articleNumber: {type: 'input', label: 'Artikelnummer'},
isAlternative: {type: 'checkbox', label: 'Alternativposition'},
unitPrice: {type: 'input', label: 'Einzelpreis', inputType: 'number'},
discount: {type: 'input', label: 'Rabatt (%)', inputType: 'number'},
},
validateForm: (formData) => {
const requiredFields = ['article', 'amount', 'unitPrice'];
for (const field of requiredFields) {
if (!formData[field]) {
window.notify('error', `Bitte füllen Sie ${this.positionsConfig.fields[field].label} aus`);
return false;
}
}
return true;
article: { type: 'input-article', label: 'Artikel', customFieldReference: 'WarehouseArticle' },
amount: { type: 'input', label: 'Menge', inputType: 'number', editableInTable: true },
unit: { type: 'input', label: 'Einheit' },
articleNumber: { type: 'input', label: 'Artikelnummer' },
unitPrice: { type: 'input', label: 'Einzelpreis', inputType: 'number', editableInTable: true },
discount: { type: 'input', label: 'Rabatt (%)', inputType: 'number', editableInTable: true },
comment: { type: 'input', label: 'Kommentar', editableInTable: true },
isAlternative: { type: 'checkbox', label: 'Alternativ' },
},
validateForm: (formData) => { /* ... validation logic ... */ return true; },
},
paymentTerms: [
{value: 'net30', text: '30 Tage netto'},
@@ -664,7 +892,6 @@ Vue.component('warehouse-offer-modal', {
deliveryTerms: [
{value: 'ex_works', text: 'Ab Werk'},
{value: 'free_delivery', text: 'Frei Haus'},
{value: 'fob', text: 'FOB'},
],
offer: {
editor: window.TT_CONFIG['USER_ID'],
@@ -676,41 +903,74 @@ Vue.component('warehouse-offer-modal', {
customerZip: '',
customerCity: '',
customerVAT: '',
contactPerson: '',
contactPersonEmail: '',
positions: [],
alternativePositions: [],
totalDiscount: 0,
paymentTerms: 'net30',
deliveryTerms: 'ex_works',
closingText: 'Sollten sich die Rohstoffpreise bzw. die Preise unserer Zulieferer um mehr als 10% innerhalb der Angebots bzw.\n' +
'\n' +
'Auftragsgültigkeit erhöhen (Stichtag Datum), sind wir gezwungen die Preise anzupassen.\n' +
'\n' +
'Diese Angebot hat eine Gültigkeit von 4 Wochen.\n' +
'\n' +
'Verrechnung erfolgt nach tatsächlichem Aufwand.\n' +
'\n' +
'Wir sind sicher, Ihnen ein konkurenzfähiges Angebot unterbreitet zu haben und sehen gern Ihrer Bestellung entgegen.\n' +
'\n' +
'Sollten Sie noch Fragen oder weitere Informationen benötigen stehen wir Ihnen jederzeit gern zu Verfügung.\n' +
' ',
closingText: 'Sollten sich die Rohstoffpreise bzw. die Preise unserer Zulieferer um mehr als 10% innerhalb der Angebots bzw.\nAuftragsgültigkeit erhöhen (Stichtag Datum), sind wir gezwungen die Preise anzupassen.\nDiese Angebot hat eine Gültigkeit von 4 Wochen.\nVerrechnung erfolgt nach tatsächlichem Aufwand.\nWir sind sicher, Ihnen ein konkurenzfähiges Angebot unterbreitet zu haben und sehen gern Ihrer Bestellung entgegen.\nSollten Sie noch Fragen oder weitere Informationen benötigen stehen wir Ihnen jederzeit gern zu Verfügung.',
notes: '',
},
templateName: '',
ignoreFirstAddressChange: false,
}
},
async mounted() {
if (this.id !== 'create') {
const response = await axios.get(`${window.TT_CONFIG["BASE_PATH"]}/WarehouseOffer/getById`, {params: {id: this.id}});
this.offer = response.data;
this.offer.positions = JSON.parse(this.offer.positions);
this.offer.alternativePositions = JSON.parse(this.offer.alternativePositions);
this.ignoreFirstAddressChange = true;
await this.loadOffer(this.id);
await this.loadVersions();
} else {
await this.$nextTick();
this.offer.editor = parseInt(window.TT_CONFIG['USER_ID']);
}
// Add focus-out listener to scroll up
this.$refs.positionsManagerContainer.addEventListener('focusout', () => {
this.$refs.positionsManagerContainer.scrollTop = 0;
});
},
methods: {
formatDate: ts => ts ? window.moment(ts * 1000).format('DD.MM.YYYY HH:mm') : '-',
formatPrice: price => new Intl.NumberFormat('de-AT', { style: 'currency', currency: 'EUR' }).format(price || 0),
async loadOffer(id) {
const response = await axios.get(`${window.TT_CONFIG["BASE_PATH"]}/WarehouseOffer/getById`, {params: {id}});
this.offer = response.data;
this.offer.positions = JSON.parse(this.offer.positions || '[]');
this.selectedVersion = this.offer.version;
this.isReadonly = false; // Default to editable
},
async loadVersions() {
const response = await axios.get(`${window.TT_CONFIG["BASE_PATH"]}/WarehouseOffer/getVersions`, {params: {id: this.id}});
this.versions = response.data.sort((a, b) => b.version - a.version);
if(this.versions.length > 0 && !this.selectedVersion) {
this.selectedVersion = this.versions[0].version;
}
},
loadVersion() {
const versionData = this.versions.find(v => v.version == this.selectedVersion);
if (versionData && versionData.data) {
this.offer = versionData.data;
// Ensure positions is always an array
this.offer.positions = typeof versionData.data.positions === 'string' ? JSON.parse(versionData.data.positions || '[]') : (versionData.data.positions || []);
this.isReadonly = this.selectedVersion != this.versions[0].version; // Only latest version is editable
window.notify('info', `Version ${this.selectedVersion} geladen. Nur die aktuellste Version ist bearbeitbar.`);
}
},
openVersionPDF() {
if (!this.selectedVersion) {
window.notify('error', 'Keine Version ausgewählt.');
return;
}
window.open(`${window.TT_CONFIG['BASE_PATH']}/WarehouseOffer/createPDF?id=${this.id}&version=${this.selectedVersion}`);
},
applyClosingText(text) {
this.offer.closingText = text;
this.showClosingTextModal = false;
},
async submit() {
if (this.isReadonly) {
return window.notify('info', 'Alte Versionen können nicht gespeichert werden. Erstellen Sie eine neue Version durch Bearbeiten der Aktuellsten.');
}
this.offer.totalAmount = this.offerTotalPrice;
if (this.offer.positions.length === 0) return window.notify('error', 'Bitte fügen Sie mindestens eine Position hinzu.');
@@ -718,14 +978,16 @@ Vue.component('warehouse-offer-modal', {
? `${window.TT_CONFIG["BASE_PATH"]}/WarehouseOffer/create`
: `${window.TT_CONFIG["BASE_PATH"]}/WarehouseOffer/update`;
const response = await axios.post(url, this.offer);
if (response.data.success) {
window.notify('success', response.data.message ?? 'Angebot erfolgreich gespeichert');
this.$emit('close');
} else {
window.notify('error',
response.data.errors ? Object.values(response.data.errors).join('<br>') : response.data.message || 'Ein Fehler ist aufgetreten');
try {
const response = await axios.post(url, this.offer);
if (response.data.success) {
window.notify('success', response.data.message ?? 'Angebot erfolgreich gespeichert');
this.$emit('close');
} else {
window.notify('error', response.data.errors ? Object.values(response.data.errors).join('<br>') : response.data.message || 'Ein Fehler ist aufgetreten');
}
} catch (e) {
window.notify('error', 'Speichern fehlgeschlagen: ' + e.message);
}
},
async fetchArticleData(article) {
@@ -756,34 +1018,64 @@ Vue.component('warehouse-offer-modal', {
} else {
window.notify('error', response.data.message || 'Ein Fehler ist aufgetreten');
}
},
trackManualAddressChange() {
if (this.id === 'create' || !this.isReadonly) {
this.isAddressManuallyChanged = true;
}
}
},
watch: {
'offer.customerNumber': async function () {
if (!this.offer.customerNumber) return;
const response = await axios.get(`${window.TT_CONFIG["BASE_PATH"]}/Address/api?do=getAddress&id=${this.offer.customerNumber}`);
if (response.data.status !== 'OK' || !response.data.result.address) {
this.window.notify('error', 'Kundenadresse konnte nicht gefunden werden');
'offer.customerNumber': async function (newVal, oldVal) {
if (!newVal || this.isAddressManuallyChanged) return;
if (this.ignoreFirstAddressChange) {
this.ignoreFirstAddressChange = false;
return;
}
const response = await axios.get(`${window.TT_CONFIG["BASE_PATH"]}/Address/api?do=getAddress&id=${this.offer.customerNumber}`);
if (response.data.status !== 'OK' || !response.data.result.address) {
return;
}
const address = response.data.result.address;
this.offer.customerName = address.company || `${address.firstname} ${address.lastname}`;
this.offer.customerStreet = address.street;
this.offer.customerZip = address.zip;
this.offer.customerCity = address.city;
}
this.offer.customerVAT = address.vat_number || '';
this.offer.contactPersonEmail = address.email || '';
},
'offer.customerName': function() { this.trackManualAddressChange() },
'offer.customerStreet': function() { this.trackManualAddressChange() },
'offer.customerZip': function() { this.trackManualAddressChange() },
'offer.customerCity': function() { this.trackManualAddressChange() },
},
computed: {
offerTotalPrice() {
const totalPrice = this.offer.positions.reduce((total, position) => {
if (!position.amount) return total;
const discount = position.discount ? (position.unitPrice * position.amount) * position.discount / 100 : 0;
return total + (position.unitPrice * position.amount) - discount;
modalTitle() {
if (this.id === 'create') return 'Angebot erstellen';
let title = `Angebot #${this.offer.offerNumber} (v${this.selectedVersion || this.offer.version})`;
if(this.isReadonly) title += ' - Schreibgeschützt';
return title;
},
netTotalPrice() {
if (!this.offer.positions) return 0;
return this.offer.positions.reduce((total, p) => {
if (p.isAlternative || !p.amount || !p.unitPrice) return total;
const discount = p.discount ? (p.unitPrice * p.amount) * p.discount / 100 : 0;
return total + (p.unitPrice * p.amount) - discount;
}, 0);
return totalPrice - (totalPrice * this.offer.totalDiscount / 100).toFixed(2);
},
alternativeTotalPrice() {
if (!this.offer.positions) return 0;
return this.offer.positions.reduce((total, p) => {
if (!p.isAlternative || !p.amount || !p.unitPrice) return total;
const discount = p.discount ? (p.unitPrice * p.amount) * p.discount / 100 : 0;
return total + (p.unitPrice * p.amount) - discount;
}, 0);
},
offerTotalPrice() {
const total = this.netTotalPrice;
return total - (total * (this.offer.totalDiscount || 0) / 100);
}
}
});
@@ -792,20 +1084,16 @@ Vue.component('warehouse-offer', {
template: `
<tt-card>
<warehouse-offer-modal v-if="offerModalId" :id="offerModalId" ref="modal"
@close="offerModalId = null;$refs.table.$refs.table.refreshTable()"/>
<warehouse-offer-create-basic-offer-modal
v-if="showBasicModal"
:show="true"
@close="showBasicModal = false"
@created="showBasicModal = false"/>
@close="closeModal"/>
<send-mail-modal v-if="sendMailModalId" :offer-id="sendMailModalId" @close="closeModal"/>
<div style="display: flex; gap: 8px">
<button @click="offerModalId = 'create'" class="btn btn-primary">Angebot erstellen</button>
<div class="dropdown">
<div class="d-flex" style="gap: 8px; margin-bottom: 1rem;">
<tt-button text="Angebot erstellen" @click="offerModalId = 'create'" additional-class="btn-primary"/>
<div class="dropdown" id="offer-templates-dropdown">
<button class="btn btn-outline-primary dropdown-toggle" @click.stop="offerTemplatesDropdown = !offerTemplatesDropdown" >
Angebot aus Vorlage erstellen <i class="fas fa-caret-down"></i>
Vorlage <i class="fas fa-caret-down"></i>
</button>
<ul class="dropdown-menu" :class="{'show': offerTemplatesDropdown}" >
<ul class="dropdown-menu" :class="{'show': offerTemplatesDropdown}" @mouseleave="offerTemplatesDropdown = false">
<li v-for="template in offerTemplates" @click="createOfferFromTemplate(template)" style="display: flex; gap: 2px;cursor: pointer;margin-bottom: 4px;margin-right: 4px">
<a class="dropdown-item">{{ template.templateName }}</a>
<tt-button
@@ -814,19 +1102,20 @@ Vue.component('warehouse-offer', {
</li>
</ul>
</div>
<button @click="showBasicModal = true" class="btn btn-success">
<i class="fas fa-plus"></i> Einfaches Angebot erstellen
</button>
</div>
<tt-table-crud emit-edit @edit="offerModalId = $event.id" ref="table"
@openpdf="window.open(window.TT_CONFIG['BASE_PATH'] + '/WarehouseOffer/createPDF?id=' + $event.id)"
<tt-table-crud emit-edit
@edit="offerModalId = $event.id"
ref="table"
@openpdf="openPDF($event)"
@sendmail="sendMailModalId = $event.id"
>
<template v-slot:expandedRow="{ row }">
<warehouse-offer-detail :id="row.id"/>
</template>
<template v-slot:totalamount="{ row }">{{ formatPrice(row.totalAmount) }}</template>
<template v-slot:create="{ row }">{{ formatDate(row.create) }}</template>
<template v-slot:lastsentdate="{ row }">{{ formatDate(row.lastSentDate) }}</template>
</tt-table-crud>
</tt-card>
`,
@@ -834,37 +1123,311 @@ Vue.component('warehouse-offer', {
return {
window: window,
offerModalId: null,
sendMailModalId: null,
offerTemplates: [],
offerTemplatesDropdown: false,
showBasicModal: false,
}
},
async mounted() {
const response = await axios.get(`${window.TT_CONFIG["BASE_PATH"]}/WarehouseOffer/getTemplates`);
this.offerTemplates = response.data;
await this.loadTemplates();
document.addEventListener('click', this.closeDropdown);
},
beforeDestroy() {
document.removeEventListener('click', this.closeDropdown);
},
methods: {
formatDate: ts => ts ? window.moment(ts * 1000).format('DD.MM.YYYY') : '-',
formatPrice: price => new Intl.NumberFormat('de-AT', { style: 'currency', currency: 'EUR' }).format(price || 0),
async closeModal() {
this.offerModalId = null;
this.sendMailModalId = null;
await new Promise(resolve => setTimeout(resolve, 250));
this.$refs.table.$refs.table.refreshTable();
},
async loadTemplates() {
const response = await axios.get(`${window.TT_CONFIG["BASE_PATH"]}/WarehouseOffer/getTemplates`);
this.offerTemplates = response.data;
},
openPDF(offer) {
window.open(`${window.TT_CONFIG['BASE_PATH']}/WarehouseOffer/createPDF?id=${offer.id}&version=${offer.version}`)
},
closeDropdown(event) {
if (!event.target.closest('#offer-templates-dropdown')) {
this.offerTemplatesDropdown = false;
}
},
async createOfferFromTemplate(template) {
this.offerTemplatesDropdown = false;
this.offerModalId = 'create';
await this.$nextTick();
this.$refs.modal.offer.positions = JSON.parse(template.positions);
this.$refs.modal.offer.totalDiscount = template.totalDiscount;
this.$refs.modal.offer.paymentTerms = template.paymentTerms;
this.$refs.modal.offer.deliveryTerms = template.deliveryTerms;
this.$refs.modal.offer.closingText = template.closingText;
this.$refs.modal.offer.notes = template.notes;
this.window.notify('success', 'Angebot aus Vorlage erstellt');
window.notify('success', 'Angebot aus Vorlage erstellt');
},
async deleteTemplate(id) {
if(!confirm('Vorlage wirklich löschen?')) return;
const response = await axios.get(`${window.TT_CONFIG["BASE_PATH"]}/WarehouseOffer/deleteTemplate?id=${id}`);
if (response.data.success) {
this.offerTemplates = this.offerTemplates.filter(template => template.id !== id);
this.window.notify('success', 'Vorlage erfolgreich gelöscht');
await this.loadTemplates();
window.notify('success', 'Vorlage erfolgreich gelöscht');
} else {
this.window.notify('error', response.data.message || 'Ein Fehler ist aufgetreten');
window.notify('error', response.data.message || 'Ein Fehler ist aufgetreten');
}
}
}
});
Vue.component('warehouse-offer-detail', {
template: `
<div class="offer-detail-container">
<div v-if="loading" class="text-center p-5"><i class="fas fa-spinner fa-spin fa-2x"></i></div>
<div v-else class="offer-detail-grid">
<!-- Left Column: Offer Info -->
<div class="offer-info-pane">
<h5 class="pane-title"><i class="fas fa-info-circle mr-2"></i>Übersicht</h5>
<div class="info-grid">
<div class="info-item">
<label>Kunde</label>
<span>{{ offer.customerName }}</span>
</div>
<div class="info-item">
<label>Zweck</label>
<span>{{ offer.purpose || '-' }}</span>
</div>
<div class="info-item">
<label>Summe</label>
<span>{{ formatPrice(offer.totalAmount) }}</span>
</div>
<div class="info-item">
<label>Status</label>
<span class="badge" :class="statusInfo.badgeClass">{{ statusInfo.text }}</span>
</div>
<div class="info-item">
<label>Sachbearbeiter</label>
<span>{{ editorName }}</span>
</div>
<div class="info-item">
<label>Erstellt am</label>
<span>{{ formatDate(offer.create, 'DD.MM.YYYY') }}</span>
</div>
</div>
</div>
<!-- Right Column: Journal -->
<div class="offer-journal-pane">
<h5 class="pane-title"><i class="fas fa-history mr-2"></i>Journal</h5>
<div class="journal-box mb-3">
<div v-if="journal.length === 0" class="text-center text-muted p-4">Noch keine Einträge vorhanden.</div>
<div v-for="log in journal" class="journal-entry-styled">
<div class="journal-avatar" :style="{ backgroundColor: userColor(log.createByName) }">
<span>{{ log.createByName ? log.createByName.charAt(0) : '?' }}</span>
</div>
<div class="journal-content">
<div class="journal-header">
<strong>{{ log.createByName }}</strong>
<span class="text-muted text-xs ml-auto">{{ formatDate(log.create, 'relative') }}</span>
</div>
<p class="journal-message" v-if="log.message">{{ log.message }}</p>
<div v-if="log.fileIds && JSON.parse(log.fileIds).length > 0" class="mt-2">
<tt-file v-for="fileId in JSON.parse(log.fileIds)" :key="fileId" :id="fileId"/>
</div>
</div>
</div>
</div>
<div class="new-journal-entry">
<tt-textarea v-model="newMessage" placeholder="Neuer Journaleintrag..." no-form-group class="flex-grow-1" rows="2"/>
<div class="new-journal-actions">
<tt-file-upload-light ref="fileUpload" multiple @uploaded="onFileUploaded" button-text="" />
<tt-button text="Speichern" @click="addJournalEntry" :loading="savingJournal" sm/>
</div>
</div>
</div>
</div>
</div>
`,
props: ['id'],
data: () => ({
offer: {},
journal: [],
loading: true,
savingJournal: false,
newMessage: '',
uploadedFiles: [],
userColors: {}
}),
async mounted() {
const [offerResponse, journalResponse] = await Promise.all([
axios.get(`${window.TT_CONFIG.BASE_PATH}/WarehouseOffer/getById`, {params: {id: this.id}}),
axios.get(`${window.TT_CONFIG.BASE_PATH}/WarehouseOffer/getJournal`, {params: {id: this.id}})
]);
this.offer = offerResponse.data;
this.journal = journalResponse.data.sort((a,b) => b.create - a.create); // Ensure descending order
this.loading = false;
},
methods: {
formatDate(ts, format = 'DD.MM.YYYY HH:mm') {
if (!ts) return '-';
if (format === 'relative') {
return window.moment(ts * 1000).fromNow();
}
return window.moment(ts * 1000).format(format);
},
formatPrice: price => new Intl.NumberFormat('de-AT', { style: 'currency', currency: 'EUR' }).format(price || 0),
onFileUploaded(file) {
this.uploadedFiles.push(file.id);
},
async addJournalEntry() {
if (!this.newMessage && this.uploadedFiles.length === 0) return;
this.savingJournal = true;
try {
await axios.post(`${window.TT_CONFIG.BASE_PATH}/WarehouseOffer/addJournalEntry`, {
id: this.id,
message: this.newMessage,
fileIds: this.uploadedFiles
});
this.newMessage = '';
this.uploadedFiles = [];
if (this.$refs.fileUpload) this.$refs.fileUpload.reset();
const journalResponse = await axios.get(`${window.TT_CONFIG.BASE_PATH}/WarehouseOffer/getJournal`, {params: {id: this.id}});
this.journal = journalResponse.data.sort((a,b) => b.create - a.create);
window.notify('success', 'Journaleintrag gespeichert.');
} catch (e) {
window.notify('error', 'Fehler beim Speichern des Eintrags.');
} finally {
this.savingJournal = false;
}
},
userColor(userName) {
if (!userName) return '#cccccc';
if (this.userColors[userName]) return this.userColors[userName];
let hash = 0;
for (let i = 0; i < userName.length; i++) {
hash = userName.charCodeAt(i) + ((hash << 5) - hash);
}
let color = '#';
for (let i = 0; i < 3; i++) {
let value = (hash >> (i * 8)) & 0xFF;
color += ('00' + value.toString(16)).substr(-2);
}
this.userColors[userName] = color;
return color;
}
},
computed: {
editorName() {
const users = window.TT_CONFIG.CRUD_CONFIG.columns.find(c => c.key === 'editor')?.modal?.items || [];
const user = users.find(u => u.value == this.offer.editor);
return user ? user.text : 'Unbekannt';
},
statusInfo() {
const statusMap = {
new: { text: 'Neu', badgeClass: 'badge-primary' },
sent: { text: 'Ausgeschickt', badgeClass: 'badge-info' },
accepted: { text: 'Angenommen', badgeClass: 'badge-success' },
rejected: { text: 'Abgelehnt', badgeClass: 'badge-danger' },
cancelled: { text: 'Storniert', badgeClass: 'badge-secondary' },
};
return statusMap[this.offer.status] || { text: this.offer.status, badgeClass: 'badge-light' };
}
}
});
;
Vue.component('send-mail-modal', {
props: ['offerId'],
template: `
<tt-modal :show="true" title="Angebot per E-Mail senden" @close="$emit('close')" @submit="sendEmail" :save-loading="loading">
<tt-input label="Empfänger E-Mail" v-model="email" required/>
<tt-input label="Betreff" v-model="subject" required/>
<tt-textarea label="Nachricht" v-model="body" rows="5" required/>
</tt-modal>
`,
data() {
return {
loading: false,
email: '',
subject: '',
body: 'Sehr geehrte Damen und Herren,\n\nanbei erhalten Sie das angeforderte Angebot.\n\nMit freundlichen Grüßen\nIhr Team der XINON GmbH'
}
},
async mounted() {
const response = await axios.get(`${window.TT_CONFIG.BASE_PATH}/WarehouseOffer/getById`, {params: {id: this.offerId}});
const offer = response.data;
this.email = offer.contactPersonEmail || '';
this.subject = `Angebot ${offer.offerNumber} von XINON GmbH`;
},
methods: {
async sendEmail() {
this.loading = true;
try {
const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WarehouseOffer/sendOfferEmail`, {
id: this.offerId,
email: this.email,
subject: this.subject,
body: this.body
});
if (response.data.success) {
window.notify('success', response.data.message);
this.$emit('close');
} else {
window.notify('error', response.data.message);
}
} catch (e) {
window.notify('error', 'E-Mail Versand fehlgeschlagen.');
} finally {
this.loading = false;
}
}
}
});
Vue.component('closing-text-modal', {
template: `
<tt-modal :show="true" title="Schlusstext-Vorlage wählen" @close="$emit('close')" :submit-button="false">
<div class="list-group">
<a href="#" v-for="template in templates" :key="template.id" class="list-group-item list-group-item-action" @click.prevent="$emit('select', template.text)">
{{ template.name }}
</a>
</div>
<hr v-if="window.TT_CONFIG['WAREHOUSE_ADMIN'] === '1'">
<div v-if="window.TT_CONFIG['WAREHOUSE_ADMIN'] === '1'">
<h5>Neue Vorlage erstellen</h5>
<tt-input label="Name" v-model="newTemplate.name" sm/>
<tt-textarea label="Text" v-model="newTemplate.text" sm rows="4"/>
<tt-button text="Vorlage speichern" @click="saveTemplate" sm/>
</div>
</tt-modal>
`,
data() {
return {
templates: [],
newTemplate: { name: '', text: '' },
window: window,
}
},
async mounted() {
await this.loadTemplates();
},
methods: {
async loadTemplates() {
const response = await axios.get(`${window.TT_CONFIG.BASE_PATH}/WarehouseOffer/getClosingTexts`);
this.templates = response.data;
},
async saveTemplate() {
if (!this.newTemplate.name || !this.newTemplate.text) {
return window.notify('error', 'Name und Text dürfen nicht leer sein.');
}
await axios.post(`${window.TT_CONFIG.BASE_PATH}/WarehouseOffer/createClosingText`, this.newTemplate);
this.newTemplate.name = '';
this.newTemplate.text = '';
await this.loadTemplates();
window.notify('success', 'Vorlage gespeichert.');
}
}
});