From 11e4d4b270188b060aade79bc0e77d8b2164bef6 Mon Sep 17 00:00:00 2001 From: Luca Haid Date: Tue, 15 Jul 2025 12:57:41 +0200 Subject: [PATCH] warehouseoffer v2 --- Layout/default/WarehouseOffer/PDF_MAIN.php | 402 +++------ .../PreorderIFrameController.php | 2 +- .../WarehouseHistoryModel.php | 80 +- .../WarehouseOfferController.php | 596 ++++++++----- .../WarehouseOffer/WarehouseOfferModel.php | 116 +++ ...50715110000_warehouse_offer_versioning.php | 117 +++ .../pages/WarehouseOffer/WarehouseOffer.css | 181 +++- .../js/pages/WarehouseOffer/WarehouseOffer.js | 821 +++++++++++++++--- 8 files changed, 1655 insertions(+), 660 deletions(-) create mode 100644 db/migrations/20250715110000_warehouse_offer_versioning.php diff --git a/Layout/default/WarehouseOffer/PDF_MAIN.php b/Layout/default/WarehouseOffer/PDF_MAIN.php index 478008f14..e61c3d58c 100644 --- a/Layout/default/WarehouseOffer/PDF_MAIN.php +++ b/Layout/default/WarehouseOffer/PDF_MAIN.php @@ -1,81 +1,63 @@ [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)); ?> @@ -84,140 +66,41 @@ $formattedValidUntil = $validUntilDate ? date("d.m.Y", $validUntilDate) : date(" <?= $text['title'] ?> <?= $offerNumber ?> @@ -234,25 +117,10 @@ $formattedValidUntil = $validUntilDate ? date("d.m.Y", $validUntilDate) : date(" - + - - - - purpose ?? 'Keine Angabe' ?> - customerVAT)) : ?> - - customerVAT) ?> - - - - - - - reference ?? 'Keine Angabe' ?> - @@ -271,122 +139,86 @@ $formattedValidUntil = $validUntilDate ? date("d.m.Y", $validUntilDate) : date(" $positions): - // Optional: Display group name if it exists + $isAlternativeGroup = ($groupName === 'Alternativpositionen'); if (!empty($groupName)): ?> - - + + - + -
- - | - - -
- -
- +
+
+
Kommentar:
- + - - + + - - - - - - Keine Positionen im Angebot enthalten. - - + + -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 --- -?> - - - - - - - - 0): - // Define a label for the discount. You can add 'discount' to your $text array. - $discountLabel = $text['summary']['discount'] ?? 'Rabatt'; - ?> +
:
+ - - + + - + +
(%):- + 0): ?> +
+ : + +
+ (nicht im Gesamtbetrag enthalten) +
+ +
+ + + + + + + 0): ?> + + + + + + + + + + + + + + + + +
Zwischensumme:
(%):-
:
:
+
- - - : - - - - - - : - - - - - -
- -

-
-
- -

-

paymentTerms] ?? $text['paymentTerms']['immediate'] ?>

-

Lieferzeit: nach Vereinbarung.

-

closingText ?? '')) ?>

+
+ +
+
- \ No newline at end of file + diff --git a/application/PreorderIFrame/PreorderIFrameController.php b/application/PreorderIFrame/PreorderIFrameController.php index 90e9e5b9b..8bf70bae2 100644 --- a/application/PreorderIFrame/PreorderIFrameController.php +++ b/application/PreorderIFrame/PreorderIFrameController.php @@ -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; diff --git a/application/WarehouseHistory/WarehouseHistoryModel.php b/application/WarehouseHistory/WarehouseHistoryModel.php index 4c90decf5..152802015 100644 --- a/application/WarehouseHistory/WarehouseHistoryModel.php +++ b/application/WarehouseHistory/WarehouseHistoryModel.php @@ -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; + } } diff --git a/application/WarehouseOffer/WarehouseOfferController.php b/application/WarehouseOffer/WarehouseOfferController.php index 2c1562682..6bc9fea13 100644 --- a/application/WarehouseOffer/WarehouseOfferController.php +++ b/application/WarehouseOffer/WarehouseOfferController.php @@ -1,326 +1,484 @@ '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'] = "Empfänger"; // 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'] = "Rechnungsadresse"; - $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'] = "Rechnungsadresse"; + 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,

das Angebot {$offer->offerNumber} für Kunde {$offer->customerName} übersteigt die Summe von 5.000,00 € und erfordert Ihre Freigabe.

"; + $body .= "Gesamtsumme: " . number_format($offer->totalAmount, 2, ',', '.') . " €
"; + $body .= "Erstellt von: " . (UserModel::getOne($offer->editor))->name . "

"; + $body .= "Bitte prüfen und genehmigen Sie das Angebot im System.

Danke,
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) . ",

"; + $body .= "hiermit möchten wir Sie an unser Angebot mit der Nummer {$offer->offerNumber} vom " . date('d.m.Y', $offer->create) . " erinnern.

"; + $body .= "Sollten Sie Fragen haben oder eine Anpassung wünschen, stehen wir Ihnen gerne zur Verfügung.

Mit freundlichen Grüßen,
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' => "Empfänger", + '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) ? - "Ihre Referenz: " . htmlspecialchars($offer->extReference) : "", // Added htmlspecialchars + 'basedir' => BASEDIR, + 'externalReference' => !empty($offerData->reference) ? + "Ihre Referenz: " . 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()); + } } diff --git a/application/WarehouseOffer/WarehouseOfferModel.php b/application/WarehouseOffer/WarehouseOfferModel.php index cbfa931e4..239e66d03 100644 --- a/application/WarehouseOffer/WarehouseOfferModel.php +++ b/application/WarehouseOffer/WarehouseOfferModel.php @@ -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; + } +} + diff --git a/db/migrations/20250715110000_warehouse_offer_versioning.php b/db/migrations/20250715110000_warehouse_offer_versioning.php new file mode 100644 index 000000000..32441a5aa --- /dev/null +++ b/db/migrations/20250715110000_warehouse_offer_versioning.php @@ -0,0 +1,117 @@ +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(); + } +} \ No newline at end of file diff --git a/public/js/pages/WarehouseOffer/WarehouseOffer.css b/public/js/pages/WarehouseOffer/WarehouseOffer.css index 04c5eee0c..3d6aeb590 100644 --- a/public/js/pages/WarehouseOffer/WarehouseOffer.css +++ b/public/js/pages/WarehouseOffer/WarehouseOffer.css @@ -1,5 +1,182 @@ @media (min-width: 992px) { .modal-lg, .modal-xl { - max-width: min(90vw) !important; + max-width: min(90vw, 1400px) !important; } -} \ No newline at end of file +} + +/* 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; +} diff --git a/public/js/pages/WarehouseOffer/WarehouseOffer.js b/public/js/pages/WarehouseOffer/WarehouseOffer.js index d2006dcd9..f02b8afe8 100644 --- a/public/js/pages/WarehouseOffer/WarehouseOffer.js +++ b/public/js/pages/WarehouseOffer/WarehouseOffer.js @@ -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: ` +
+ + + + + + + +
+
+
+ + {{ file.file.name }} + ({{ formatSize(file.file.size) }}) +
+
+ +
+
+
+ + Fehler + Fertig + + + +
+
+
+
+ `, + 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: ` -

Angebotsdetails

- - - - - -
-

Kundenadresse

-
- - - - - - + :title="modalTitle" + @update:show="$emit('close')" + size="xl"> +
+ +

Angebotsdetails

+
+ + + +
+ + +
+
Kunde & Kontakt
+
+
+
+ +
+
+ +
+
+ +
+
+ + +
+
+ + +
+
Kundenadresse
+
+
+ + + + + +
+
+
+ + +
+
Positionen
+
+ +
+
+ + +
+
Konditionen & Schlusstext
+
+
+
+ + + +
+
+
Nettobetrag: {{ formatPrice(netTotalPrice) }} €
+
Alternativbetrag: {{ formatPrice(alternativeTotalPrice) }} €
+

Gesamtbetrag: {{ formatPrice(offerTotalPrice) }} €

+
+
+
+
+ + +
+ +
+ +
-
-

Positionen

- -
- - - - - -
-
+ + -