'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' => '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' => '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' => 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'] = true; } } 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; $this->postData['validity'] = 14; $this->postData['alternativePositions'] = json_encode([]); return true; } protected function afterCreate($offer): void { $id = $offer['id']; $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; } 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() ]); } 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); } // 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; if (!$id) self::sendError("ID fehlt"); WarehouseOfferJournalModel::create([ 'offerId' => $id, 'message' => $message, 'fileIds' => $fileIds, 'createBy' => $this->user->id, 'create' => time() ]); self::returnJson(['success' => true, 'message' => 'Journaleintrag hinzugefügt.']); } // Closing Text Actions public function getClosingTextsAction() { self::returnJson(WarehouseOfferClosingTextModel::getAll()); } public function createClosingTextAction() { if (!$this->user->can('WarehouseAdmin')) self::sendError("Keine Berechtigung"); $_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]); } public function deleteClosingTextAction() { if (!$this->user->can('WarehouseAdmin')) self::sendError("Keine Berechtigung"); WarehouseOfferClosingTextModel::delete($this->request->id); self::returnJson(['success' => true]); } // E-Mail Actions public function sendOfferEmailAction() { //display errors for debugging error_reporting(E_ALL); ini_set('display_errors', 1); ini_set('display_startup_errors', 1); $_POST = json_decode(file_get_contents('php://input'), true); $id = $_POST['id'] ?? null; $recipientEmail = $_POST['email'] ?? null; $subject = $_POST['subject'] ?? 'Ihr Angebot von XINON GmbH'; $bodyText = $_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) { self::sendError("Angebot nicht gefunden."); } // 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; } // Generate PDF $pdfContent = $this->createPDFAction(true, $id, true); // --- HTML Email Generation --- $logoToolPath = BASEDIR . '/public/assets/images/the-tool-logo.png'; $logoXinonPath = BASEDIR . '/public/assets/images/xinon-full.png'; $logoToolExists = file_exists($logoToolPath); $logoXinonExists = file_exists($logoXinonPath); // Construct HTML Body $html = 'Angebot'; $html .= '
'; // Logos $html .= '
'; if ($logoToolExists) $html .= 'The Tool'; if ($logoXinonExists) $html .= 'Xinon'; $html .= '
'; $html .= '

' . htmlspecialchars($subject) . '

'; $html .= '
'; $html .= nl2br(htmlspecialchars($bodyText)); $html .= '
'; $html .= '
'; $html .= 'XINON GmbH | www.xinon.at'; $html .= '
'; $mail = new PHPMailer(true); try { // Server settings $mail->isSMTP(); $mail->Host = TT_PIPEWORK_SMTP_HOST; $mail->SMTPAuth = true; $mail->Username = TT_PIPEWORK_SMTP_USER; $mail->Password = TT_PIPEWORK_SMTP_PASS; $mail->CharSet = PHPMailer::CHARSET_UTF8; $mail->Encoding = 'base64'; $mail->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; $mail->Port = 587; // Logos if ($logoToolExists) $mail->addEmbeddedImage($logoToolPath, 'logo_thetool'); if ($logoXinonExists) $mail->addEmbeddedImage($logoXinonPath, 'logo_xinon'); $mail->addReplyTo('backoffice@xinon.at', 'XINON Backoffice'); $mail->setFrom('thetool@xinon.at', 'XINON TheTool'); // set replyto to backoffice@xinon.at $mail->addAddress($recipientEmail, $offer->contactPerson ?? $offer->customerName); $mail->Subject = ($subject); $mail->isHTML(true); $mail->Body = $html; $mail->AltBody = strip_tags($bodyText); $mail->addStringAttachment($pdfContent, $offer->offerNumber . '_Angebot.pdf', 'base64', 'application/pdf'); $mail->send(); // Update offer status and last sent date $WarehouseOffer = (array) WarehouseOfferModel::get($id); $WarehouseOffer['status'] = 'sent'; $WarehouseOffer['lastSentDate'] = time(); WarehouseOfferModel::update($WarehouseOffer); // 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.']); } catch (Exception $e) { 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 = is_string($offerData->positions) ? json_decode($offerData->positions, true) : (array)$offerData->positions; $entries = []; $subTotal = 0; $alternativeTotal = 0; if (is_array($positionsRaw)) { 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($p['article']); if (!$article) continue; $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['discount'] = isset($p['discount']) ? (float)$p['discount'] : 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 $isAlternative = isset($p['isAlternative']) && $p['isAlternative']; if (!$isAlternative) { $subTotal += $p['totalPrice']; } else { $alternativeTotal += $p['totalPrice']; } $groupKey = $isAlternative ? 'Alternativpositionen' : ($p['_group'] ?? ''); if (!isset($entries[$groupKey])) $entries[$groupKey] = []; $entries[$groupKey][] = $p; } } $editor = UserModel::getOne($offerData->editor); // --- Prepare PDF Variables --- $pdf_vars = [ "offer" => $offerData, "entries" => $entries, "subTotal" => $subTotal, "alternativeTotal" => $alternativeTotal, "offerNumber" => $offerData->offerNumber, "offerDate" => $offerData->create, "offerEditorName" => $editor ? $editor->name : 'Unbekannt', "includeTax" => true, "vatRate" => 0.20, "offerText" => $offerData->notes ?? '', "validity" => $offerData->validity ?? 14, "closingText" => $offerData->closingText ?? '', "bank_iban" => TT_INVOICE_BANK_IBAN, "bank_bic" => TT_INVOICE_BANK_BIC, ]; // --- 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, 'externalReference' => !empty($offerData->reference) ? "Ihre Referenz: " . htmlspecialchars($offerData->reference) : "", // 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 (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']), // 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 PDF --- $tempDir = BASEDIR . "/var/temp"; if (!is_dir($tempDir)) mkdir($tempDir, 0775, true); $headerFile = tempnam($tempDir, 'offer_header') . '.html'; $footerFile = tempnam($tempDir, 'offer_footer') . '.html'; file_put_contents($headerFile, $this->generateTemplate('WarehouseOffer/PDF_HEADER', $replacements)); file_put_contents($footerFile, $this->generateTemplate('WarehouseOffer/PDF_FOOTER', $replacements)); $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; } if (!file_exists($filename)) self::sendError('Generierte PDF-Datei nicht gefunden.'); header('Content-Type: application/pdf'); $outputFilename = ($offerData->offerNumber ?? $offerData->id) . "_v" . ($version ?? $offerData->version) . ".pdf"; header('Content-Disposition: inline; filename="' . basename($outputFilename) . '"'); header('Content-Length: ' . filesize($filename)); readfile($filename); exit; } protected function generateTemplate(string $templateName, array $replacements): string { $templatePath = BASEDIR . "/Layout/default/" . $templateName . ".html"; if (!file_exists($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()); } }