updated warehouseoffer
This commit is contained in:
@@ -59,7 +59,6 @@
|
||||
<table style="width: 100%; border-collapse: collapse;">
|
||||
<tr>
|
||||
<td class="customer-details">
|
||||
<h3>{{ addressLine_header }}</h3>
|
||||
<div>{{ addressLine_1 }}</div>
|
||||
<div>{{ addressLine_2 }}</div>
|
||||
<div>{{ addressLine_3 }}</div>
|
||||
|
||||
@@ -29,6 +29,9 @@ $texts = [
|
||||
'title' => 'Angebot',
|
||||
'offerNumberLabel' => 'Angebotsnr.:',
|
||||
'offerDateLabel' => 'Datum:',
|
||||
'editorLabel' => 'Sachbearbeiter:',
|
||||
'usageLabel' => 'Zweck:',
|
||||
'customerReferenceLabel' => 'Kundenreferenz:',
|
||||
'validUntilLabel' => 'Gültig bis:',
|
||||
'pageLabel' => 'Seite', // For page numbering in content if needed
|
||||
'table' => [
|
||||
@@ -40,16 +43,19 @@ $texts = [
|
||||
'unitPrice' => 'Einzelpreis',
|
||||
'totalPrice' => 'Gesamtpreis'
|
||||
],
|
||||
'paymentTerms' => [
|
||||
'net30' => 'Zahlungsbedingungen: 30 Tage netto',
|
||||
'net60' => 'Zahlungsbedingungen: 60 Tage netto',
|
||||
'immediate' => 'Zahlungsbedingungen: Sofort fällig'
|
||||
],
|
||||
'summary' => [
|
||||
'subTotal' => 'Zwischensumme',
|
||||
'subTotal' => 'Nettobetrag',
|
||||
'vatFormatted' => 'zzgl. {VAT_RATE}% MwSt.', // Placeholder for rate
|
||||
'total' => 'Gesamtbetrag',
|
||||
'currency' => '€' // Currency symbol
|
||||
],
|
||||
'notes' => 'Anmerkungen:',
|
||||
'defaultOfferText' => 'Vielen Dank für Ihre Anfrage. Es gelten unsere Allgemeinen Geschäftsbedingungen.',
|
||||
'taxInfoNet' => '(Alle Preise exkl. MwSt.)', // Info if tax is added at the end
|
||||
'taxInfoGross' => '(Alle Preise inkl. MwSt.)', // Info if prices already include tax (less common in B2B offers)
|
||||
]
|
||||
// Add 'EN' => [...] section if needed
|
||||
];
|
||||
@@ -220,15 +226,28 @@ $formattedValidUntil = $validUntilDate ? date("d.m.Y", $validUntilDate) : date("
|
||||
<div class="header-info">
|
||||
<table>
|
||||
<tr>
|
||||
<td class="label"><?= $text['offerNumberLabel'] ?></td>
|
||||
<td class="label" style="text-align: left"><?= $text['offerNumberLabel'] ?></td>
|
||||
<td><?= htmlspecialchars($offerNumber) ?></td>
|
||||
<td class="label"><?= $text['offerDateLabel'] ?></td>
|
||||
<td><?= $formattedOfferDate ?></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td></td><td></td> <td class="label"><?= $text['validUntilLabel'] ?></td>
|
||||
<td class="label" style="text-align: left"><?= $text['editorLabel'] ?></td>
|
||||
<td><?= $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>
|
||||
</tr>
|
||||
|
||||
|
||||
<tr>
|
||||
<td class="label" style="text-align: left"><?= $text['customerReferenceLabel'] ?></td>
|
||||
<td><?= $offer->reference ?? 'Keine Angabe' ?></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -260,7 +279,12 @@ $formattedValidUntil = $validUntilDate ? date("d.m.Y", $validUntilDate) : date("
|
||||
<tr>
|
||||
<td class="pos"><?= $posCounter ?></td>
|
||||
<td class="article">
|
||||
<div class="article-title"><?= htmlspecialchars($p['articleText']) ?></div>
|
||||
<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; ?>
|
||||
@@ -307,14 +331,6 @@ $formattedValidUntil = $validUntilDate ? date("d.m.Y", $validUntilDate) : date("
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<?php // Tax Information Note
|
||||
if ($includeTax) {
|
||||
echo '<div class="tax-info" style="clear: both;">' . $text['taxInfoNet'] . '</div>';
|
||||
}
|
||||
// Add taxInfoGross if your prices *include* tax, which is less common for B2B offers.
|
||||
?>
|
||||
|
||||
|
||||
<div class="offer-text">
|
||||
<?php if (!empty($offerText)): ?>
|
||||
<p><strong><?= $text['notes'] ?></strong></p>
|
||||
@@ -322,8 +338,9 @@ if ($includeTax) {
|
||||
<br>
|
||||
<?php endif; ?>
|
||||
<p><?= $text['defaultOfferText'] // Add default closing text ?></p>
|
||||
<p>Zahlungsbedingungen: 14 Tage netto.</p>
|
||||
<p><?= $text['paymentTerms'][$offer->paymentTerms] ?? $text['paymentTerms']['immediate'] ?></p>
|
||||
<p>Lieferzeit: nach Vereinbarung.</p>
|
||||
<p><?= nl2br(htmlspecialchars($offer->closingText ?? '')) ?></p>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
|
||||
@@ -24,6 +24,7 @@ class WarehouseArticleController extends TTCrud {
|
||||
];
|
||||
|
||||
protected array $autocompleteColumns = ['articleNumber', 'title', 'description'];
|
||||
protected array $permissionCheck = ['WarehouseUser'];
|
||||
|
||||
protected array $additionalActions = [['key' => 'openHistory','title' => 'Historie','class' => 'fas fa-history text-secondary']];
|
||||
// @formatter:on
|
||||
|
||||
@@ -148,8 +148,8 @@ class WarehouseOfferController extends TTCrud {
|
||||
$article = WarehouseArticleModel::get($position['article']);
|
||||
if (!$article) continue; // Skip if article not found
|
||||
|
||||
$position['articleText'] = $article->title ?? 'N/A';
|
||||
// Avoid showing description if same as title
|
||||
$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
|
||||
@@ -170,6 +170,9 @@ class WarehouseOfferController extends TTCrud {
|
||||
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
|
||||
|
||||
|
||||
// --- Prepare PDF Variables ---
|
||||
$pdf_vars = [
|
||||
@@ -179,6 +182,7 @@ class WarehouseOfferController extends TTCrud {
|
||||
// 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
|
||||
|
||||
@@ -1,33 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Class WarehouseOfferModel
|
||||
*
|
||||
* Represents a warehouse offer with customer details and related metadata.
|
||||
*
|
||||
* @property int $id Unique identifier for the warehouse offer
|
||||
* @property string $offerNumber Unique offer number
|
||||
* @property string $reference Reference number for the offer
|
||||
* @property string $customerNumber Customer number
|
||||
* @property string $customerName Name of the customer
|
||||
* @property string $customerStreet Street address of the customer
|
||||
* @property string $customerCity City of the customer
|
||||
* @property string $customerZip Postal code of the customer
|
||||
* @property string $customerVAT VAT number of the customer
|
||||
* @property int $editor ID of the editor who last modified the offer
|
||||
* @property string $purpose Purpose of the offer
|
||||
* @property string $positions Details about positions in the offer
|
||||
* @property string $alternativePositions Details about alternative positions in the offer
|
||||
* @property float $totalDiscount Total discount applied to the offer
|
||||
* @property string $paymentTerms Payment terms for the offer
|
||||
* @property string $deliveryTerms Delivery terms for the offer
|
||||
* @property string $closingText Closing text for the offer
|
||||
* @property string $notes Additional notes for the offer
|
||||
* @property string $status Current status of the offer
|
||||
* @property float $totalAmount Total amount of the offer
|
||||
* @property int $create Timestamp of the offer creation
|
||||
* @property int $createBy ID of the user who created the offer
|
||||
*/
|
||||
class WarehouseOfferModel extends TTCrudBaseModel {
|
||||
public int $id;
|
||||
public string $offerNumber;
|
||||
@@ -37,7 +9,7 @@ class WarehouseOfferModel extends TTCrudBaseModel {
|
||||
public string $customerStreet;
|
||||
public string $customerCity;
|
||||
public string $customerZip;
|
||||
public string $customerVAT;
|
||||
public ?string $customerVAT;
|
||||
public int $editor;
|
||||
public string $purpose;
|
||||
public string $positions;
|
||||
@@ -51,32 +23,4 @@ class WarehouseOfferModel extends TTCrudBaseModel {
|
||||
public float $totalAmount;
|
||||
public int $create;
|
||||
public int $createBy;
|
||||
}
|
||||
|
||||
//SQL TO CREATE TABLE
|
||||
/*
|
||||
CREATE TABLE `warehouse_offer` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`offerNumber` varchar(255) NOT NULL,
|
||||
`customerNumber` varchar(255) NOT NULL,
|
||||
`customerName` varchar(255) NOT NULL,
|
||||
`customerStreet` varchar(255) NOT NULL,
|
||||
`customerCity` varchar(255) NOT NULL,
|
||||
`customerZip` varchar(255) NOT NULL,
|
||||
`customerVAT` varchar(255) NOT NULL,
|
||||
`editor` int(11) NOT NULL,
|
||||
`purpose` varchar(255) NOT NULL,
|
||||
`positions` text NOT NULL,
|
||||
`alternativePositions` text NOT NULL,
|
||||
`totalDiscount` float NOT NULL,
|
||||
`paymentTerms` varchar(255) NOT NULL,
|
||||
`deliveryTerms` varchar(255) NOT NULL,
|
||||
`closingText` varchar(255) NOT NULL,
|
||||
`notes` varchar(255) NOT NULL,
|
||||
`status` varchar(255) NOT NULL,
|
||||
`totalAmount` float NOT NULL,
|
||||
`create` int(11) NOT NULL,
|
||||
`createBy` int(11) NOT NULL,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
|
||||
*/
|
||||
}
|
||||
30
db/migrations/20250604110000_warehouse_modify_23.php
Normal file
30
db/migrations/20250604110000_warehouse_modify_23.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php /** @noinspection ALL */
|
||||
declare(strict_types = 1);
|
||||
|
||||
use Phinx\Migration\AbstractMigration;
|
||||
|
||||
final class WarehouseModify23 extends AbstractMigration {
|
||||
public function up(): void {
|
||||
if ($this->getEnvironment() == "thetool") {
|
||||
$WarehouseOffer = $this->table("WarehouseOffer");
|
||||
|
||||
if ($WarehouseOffer->hasColumn("customerVAT")) {
|
||||
$WarehouseOffer
|
||||
->changeColumn("customerVAT", "string", ["limit" => 255, "null" => true])
|
||||
->update();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void {
|
||||
if ($this->getEnvironment() == "thetool") {
|
||||
$WarehouseOffer = $this->table("WarehouseOffer");
|
||||
|
||||
if ($WarehouseOffer->hasColumn("customerVAT")) {
|
||||
$WarehouseOffer
|
||||
->changeColumn("customerVAT", "string", ["limit" => 255, "null" => false])
|
||||
->update();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,580 @@
|
||||
// noinspection JSUnresolvedReference
|
||||
|
||||
Vue.component('warehouse-offer-create-basic-offer-modal', {
|
||||
props: {
|
||||
show: {type: Boolean, default: false}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
window: window,
|
||||
loading: false,
|
||||
billAddrAutoCompleteUrl: window.TT_CONFIG['BASE_PATH'] + '/Address/Api?do=findAddress',
|
||||
productSearchUrl: window.TT_CONFIG['BASE_PATH'] + '/Product/api?do=findProduct',
|
||||
|
||||
creatorSignaturePad: null,
|
||||
creatorSignatureNotes: '',
|
||||
|
||||
positionsConfig: {
|
||||
fields: {
|
||||
article: {
|
||||
type: 'autocomplete',
|
||||
label: 'Artikel',
|
||||
apiUrl: '/WarehouseArticle/autoComplete',
|
||||
customFieldReference: 'WarehouseArticle',
|
||||
},
|
||||
amount: {type: 'input', label: 'Menge', inputType: 'number'},
|
||||
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;
|
||||
},
|
||||
},
|
||||
|
||||
offer: {
|
||||
editor: window.TT_CONFIG['USER_ID'],
|
||||
customerNumber: '',
|
||||
reference: '',
|
||||
purpose: '',
|
||||
customerName: '',
|
||||
customerStreet: '',
|
||||
customerZip: '',
|
||||
customerCity: '',
|
||||
customerVAT: '',
|
||||
contactPerson: '',
|
||||
positions: [],
|
||||
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\nAuftragsgültigkeit erhöhen (Stichtag Datum), sind wir gezwungen die Preise anzupassen.\n\nDiese Angebot hat eine Gültigkeit von 4 Wochen.\n\nVerrechnung erfolgt nach tatsächlichem Aufwand.\n\nWir sind sicher, Ihnen ein konkurenzfähiges Angebot unterbreitet zu haben und sehen gern Ihrer Bestellung entgegen.\n\nSollten Sie noch Fragen oder weitere Informationen benötigen stehen wir Ihnen jederzeit gern zu Verfügung.',
|
||||
notes: '',
|
||||
},
|
||||
|
||||
// Simple product search and selection
|
||||
productSearch: '',
|
||||
searchResults: [],
|
||||
selectedProducts: [],
|
||||
showProductSearch: false,
|
||||
|
||||
paymentTerms: [
|
||||
{value: 'net30', text: '30 Tage netto'},
|
||||
{value: 'net60', text: '60 Tage netto'},
|
||||
{value: 'immediate', text: 'Sofort fällig'},
|
||||
],
|
||||
deliveryTerms: [
|
||||
{value: 'ex_works', text: 'Ab Werk'},
|
||||
{value: 'free_delivery', text: 'Frei Haus'},
|
||||
{value: 'fob', text: 'FOB'},
|
||||
],
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<tt-modal :show="show"
|
||||
@submit="submit"
|
||||
:delete="false"
|
||||
title="Einfaches Angebot erstellen"
|
||||
@update:show="$emit('close')"
|
||||
:save-loading="loading">
|
||||
<div style="width: 99%">
|
||||
|
||||
<!-- Customer Section -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="fas fa-user"></i> Kunde</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<tt-autocomplete label="Kunde suchen"
|
||||
v-model="offer.customerNumber"
|
||||
:api-url="billAddrAutoCompleteUrl"
|
||||
sm row
|
||||
placeholder="Kundenname oder -nummer eingeben"/>
|
||||
<tt-input label="Kundenreferenz"
|
||||
v-model="offer.reference"
|
||||
sm row
|
||||
placeholder="Ihre Referenz oder Bestellnummer"/>
|
||||
<tt-input label="Kontaktperson"
|
||||
v-model="offer.contactPerson"
|
||||
sm row
|
||||
placeholder="Ansprechpartner beim Kunden"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Customer Address (Auto-filled) -->
|
||||
<div class="card mb-3" v-if="offer.customerName">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="fas fa-map-marker-alt"></i> Kundenadresse</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<strong>{{ offer.customerName }}</strong><br>
|
||||
{{ offer.customerStreet }}<br>
|
||||
{{ offer.customerZip }} {{ offer.customerCity }}
|
||||
</div>
|
||||
<div class="col-md-6" v-if="offer.customerVAT">
|
||||
<small class="text-muted">USt-IdNr.: {{ offer.customerVAT }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Purpose -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="fas fa-clipboard-list"></i> Angebotszweck</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<tt-textarea label="Wofür ist dieses Angebot?"
|
||||
v-model="offer.purpose"
|
||||
rows="2"
|
||||
placeholder="Kurze Beschreibung des Projekts oder Bedarfs"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Product Selection -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0"><i class="fas fa-box"></i> Produkte</h5>
|
||||
<button type="button"
|
||||
class="btn btn-primary btn-sm"
|
||||
@click="showProductSearch = !showProductSearch">
|
||||
<i class="fas fa-plus"></i> Produkt hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
|
||||
<!-- Product Search -->
|
||||
<div v-if="showProductSearch" class="border rounded p-3 mb-3 bg-light">
|
||||
<div class="input-group mb-2">
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
v-model="productSearch"
|
||||
@input="searchProducts"
|
||||
placeholder="Produktname oder ID eingeben..."
|
||||
@keyup.enter="searchProducts">
|
||||
<div class="input-group-append">
|
||||
<button class="btn btn-outline-secondary"
|
||||
type="button"
|
||||
@click="searchProducts">
|
||||
<i class="fas fa-search"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Results -->
|
||||
<div v-if="searchResults.length" class="list-group">
|
||||
<button type="button"
|
||||
class="list-group-item list-group-item-action"
|
||||
v-for="product in searchResults"
|
||||
:key="product.value"
|
||||
@click="addProduct(product)">
|
||||
{{ product.text }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-2">
|
||||
<button type="button"
|
||||
class="btn btn-secondary btn-sm"
|
||||
@click="showProductSearch = false">
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Selected Products -->
|
||||
<div v-if="selectedProducts.length">
|
||||
<div v-for="(product, index) in selectedProducts"
|
||||
:key="index"
|
||||
class="border rounded p-3 mb-2">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-md-4">
|
||||
<strong>{{ product.name }}</strong>
|
||||
<br><small class="text-muted">{{ product.description || 'Keine Beschreibung' }}</small>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<tt-input label="Menge"
|
||||
v-model="product.amount"
|
||||
type="number"
|
||||
min="1"
|
||||
sm no-form-group/>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<tt-input label="Einzelpreis (€)"
|
||||
v-model="product.unitPrice"
|
||||
type="number"
|
||||
step="0.01"
|
||||
sm no-form-group/>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<tt-input label="Rabatt (%)"
|
||||
v-model="product.discount"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
sm no-form-group/>
|
||||
</div>
|
||||
<div class="col-md-1 text-center">
|
||||
<strong>{{ formatPrice(calculateProductTotal(product)) }} €</strong>
|
||||
</div>
|
||||
<div class="col-md-1 text-center">
|
||||
<button type="button"
|
||||
class="btn btn-danger btn-sm"
|
||||
@click="removeProduct(index)">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total -->
|
||||
<div class="row mt-3">
|
||||
<div class="col-md-8">
|
||||
<tt-input label="Gesamtrabatt (%)"
|
||||
v-model="offer.totalDiscount"
|
||||
type="number"
|
||||
min="0"
|
||||
max="100"
|
||||
sm row/>
|
||||
</div>
|
||||
<div class="col-md-4 text-right">
|
||||
<h4>Gesamtsumme: <strong>{{ formatPrice(totalPrice) }} €</strong></h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-center text-muted py-4">
|
||||
<i class="fas fa-box-open fa-3x mb-2"></i>
|
||||
<p>Noch keine Produkte ausgewählt</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Additional Positions -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="fas fa-plus-square"></i> Zusätzliche Positionen</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<tt-positions-manager group-mode ref="positionsManager" v-model="offer.positions" :config="positionsConfig"
|
||||
@updateField-article="fetchArticleData"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Terms -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="fas fa-handshake"></i> Konditionen</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<tt-select label="Zahlungskonditionen"
|
||||
:options="paymentTerms"
|
||||
v-model="offer.paymentTerms"
|
||||
sm/>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<tt-select label="Lieferkonditionen"
|
||||
:options="deliveryTerms"
|
||||
v-model="offer.deliveryTerms"
|
||||
sm/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="fas fa-signature"></i> Unterschrift Angebotsersteller</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-12">
|
||||
<tt-input v-model="creatorSignatureNotes"
|
||||
label="Unterschrieben von..."
|
||||
placeholder="Name oder i.V."
|
||||
sm/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="signature-area mb-3">
|
||||
<canvas id="creator-signature-pad" width="800" height="200" class="border rounded"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="signature-actions text-right">
|
||||
<button class="btn btn-outline-secondary btn-sm mr-2" @click="clearCreatorSignature()">
|
||||
<i class="fas fa-eraser"></i> Zurücksetzen
|
||||
</button>
|
||||
<button class="btn btn-primary btn-sm" @click="saveCreatorSignature()">
|
||||
<i class="fas fa-save"></i> Unterschrift speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0"><i class="fas fa-sticky-note"></i> Notizen</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<tt-textarea label="Interne Notizen"
|
||||
v-model="offer.notes"
|
||||
rows="3"
|
||||
placeholder="Zusätzliche Informationen (nur intern sichtbar)"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</tt-modal>
|
||||
`,
|
||||
methods: {
|
||||
clearCreatorSignature() {
|
||||
this.creatorSignaturePad.clear();
|
||||
},
|
||||
|
||||
async fetchArticleData(article) {
|
||||
if (typeof article === 'number') {
|
||||
const response = await axios.get(`${window.TT_CONFIG["BASE_PATH"]}/WarehouseArticle/getById`, {params: {id: article}});
|
||||
this.$refs.positionsManager.updateField('articleNumber', response.data.articleNumber);
|
||||
this.$refs.positionsManager.updateField('unitPrice',
|
||||
Object.values(JSON.parse(response.data.cheapestSellPrice)).find(price => price.title === 'Verkauf').price);
|
||||
this.$refs.positionsManager.updateField('unit', response.data.unit);
|
||||
}
|
||||
},
|
||||
|
||||
async saveCreatorSignature() {
|
||||
if (this.creatorSignaturePad.isEmpty()) {
|
||||
this.window.notify('error', 'Bitte eine Unterschrift hinzufügen');
|
||||
return;
|
||||
}
|
||||
|
||||
this.offer.creatorSignature = this.creatorSignaturePad.toDataURL();
|
||||
this.offer.creatorSignatureNotes = this.creatorSignatureNotes;
|
||||
|
||||
this.window.notify('success', 'Unterschrift erfolgreich gespeichert');
|
||||
},
|
||||
|
||||
async searchProducts() {
|
||||
if (!this.productSearch || this.productSearch.length < 2) {
|
||||
this.searchResults = [];
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get(this.productSearchUrl, {
|
||||
params: { q: this.productSearch }
|
||||
});
|
||||
this.searchResults = response.data || [];
|
||||
} catch (error) {
|
||||
console.error('Product search failed:', error);
|
||||
window.notify('error', 'Produktsuche fehlgeschlagen');
|
||||
this.searchResults = [];
|
||||
}
|
||||
},
|
||||
|
||||
async addProduct(searchResult) {
|
||||
if (searchResult.value === 0) return; // Skip "more results" indicator
|
||||
|
||||
try {
|
||||
// Get detailed product information
|
||||
const response = await axios.get(`${window.TT_CONFIG["BASE_PATH"]}/Product/api`, {
|
||||
params: {
|
||||
do: 'getProduct',
|
||||
product_id: searchResult.value
|
||||
}
|
||||
});
|
||||
|
||||
if (response.data.status === 'OK' && response.data.result["product"]) {
|
||||
const productData = response.data.result["product"];
|
||||
|
||||
const product = {
|
||||
id: searchResult.value,
|
||||
name: searchResult.text,
|
||||
description: productData.description || '',
|
||||
amount: 1,
|
||||
unitPrice: parseFloat(productData.price || '0'),
|
||||
discount: 0,
|
||||
unit: 'Stk',
|
||||
articleNumber: productData.id || '',
|
||||
article: searchResult.value // For compatibility with existing system
|
||||
};
|
||||
|
||||
this.selectedProducts.push(product);
|
||||
this.productSearch = '';
|
||||
this.searchResults = [];
|
||||
this.showProductSearch = false;
|
||||
|
||||
window.notify('success', 'Produkt hinzugefügt');
|
||||
} else {
|
||||
window.notify('error', 'Produktdetails konnten nicht geladen werden');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load product details:', error);
|
||||
window.notify('error', 'Fehler beim Laden der Produktdetails');
|
||||
}
|
||||
},
|
||||
|
||||
removeProduct(index) {
|
||||
this.selectedProducts.splice(index, 1);
|
||||
},
|
||||
|
||||
calculateProductTotal(product) {
|
||||
if (!product.amount || !product.unitPrice) return 0;
|
||||
|
||||
const subtotal = product.amount * product.unitPrice;
|
||||
const discount = product.discount ? (subtotal * product.discount / 100) : 0;
|
||||
return subtotal - discount;
|
||||
},
|
||||
|
||||
formatPrice(price) {
|
||||
|
||||
return parseFloat(price || '0').toFixed(2);
|
||||
},
|
||||
|
||||
async submit() {
|
||||
this.loading = true;
|
||||
|
||||
// Validation
|
||||
if (!this.offer.customerNumber) {
|
||||
this.loading = false;
|
||||
return window.notify('error', 'Bitte wählen Sie einen Kunden aus');
|
||||
}
|
||||
|
||||
if (!this.selectedProducts.length) {
|
||||
this.loading = false;
|
||||
return window.notify('error', 'Bitte fügen Sie mindestens ein Produkt hinzu');
|
||||
}
|
||||
|
||||
// Convert selected products to the format expected by the existing system
|
||||
this.offer.positions = this.selectedProducts.map(product => ({
|
||||
article: product.article,
|
||||
amount: product.amount,
|
||||
unit: product.unit,
|
||||
articleNumber: product.articleNumber,
|
||||
unitPrice: product.unitPrice,
|
||||
discount: product.discount || 0,
|
||||
isAlternative: false
|
||||
}));
|
||||
|
||||
this.offer.totalAmount = this.totalPrice;
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
`${window.TT_CONFIG["BASE_PATH"]}/WarehouseOffer/create`,
|
||||
this.offer
|
||||
);
|
||||
|
||||
if (response.data.success) {
|
||||
window.notify('success', response.data.message || 'Angebot erfolgreich erstellt');
|
||||
this.$emit('close');
|
||||
this.$emit('created', response.data);
|
||||
} else {
|
||||
window.notify('error',
|
||||
response.data.errors
|
||||
? Object.values(response.data.errors).join('<br>')
|
||||
: response.data.message || 'Ein Fehler ist aufgetreten'
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Submit failed:', error);
|
||||
window.notify('error', 'Fehler beim Speichern des Angebots');
|
||||
}
|
||||
|
||||
this.loading = false;
|
||||
},
|
||||
|
||||
resetForm() {
|
||||
this.offer = {
|
||||
editor: window.TT_CONFIG['USER_ID'],
|
||||
customerNumber: '',
|
||||
reference: '',
|
||||
purpose: '',
|
||||
customerName: '',
|
||||
customerStreet: '',
|
||||
customerZip: '',
|
||||
customerCity: '',
|
||||
customerVAT: '',
|
||||
contactPerson: '',
|
||||
positions: [],
|
||||
totalDiscount: 0,
|
||||
paymentTerms: 'net30',
|
||||
deliveryTerms: 'ex_works',
|
||||
closingText: this.offer.closingText, // Keep default text
|
||||
notes: '',
|
||||
};
|
||||
this.selectedProducts = [];
|
||||
this.productSearch = '';
|
||||
this.searchResults = [];
|
||||
this.showProductSearch = false;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.$nextTick(() => {
|
||||
console.log(document.getElementById('creator-signature-pad'));
|
||||
const canvas = document.getElementById('creator-signature-pad');
|
||||
this.creatorSignaturePad = new SignaturePad(canvas, {
|
||||
penColor: '#000000',
|
||||
penWidth: 2,
|
||||
velocityFilterWeight: 0.5
|
||||
});
|
||||
});
|
||||
},
|
||||
computed: {
|
||||
totalPrice() {
|
||||
const subtotal = this.selectedProducts.reduce((total, product) => {
|
||||
return total + this.calculateProductTotal(product);
|
||||
}, 0);
|
||||
|
||||
const totalDiscount = this.offer.totalDiscount ? (subtotal * this.offer.totalDiscount / 100) : 0;
|
||||
return subtotal - totalDiscount;
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'offer.customerNumber': async function() {
|
||||
if (!this.offer.customerNumber) return;
|
||||
|
||||
try {
|
||||
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) {
|
||||
window.notify('error', 'Kundenadresse konnte nicht gefunden werden');
|
||||
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 || '';
|
||||
} catch (error) {
|
||||
console.error('Failed to load customer address:', error);
|
||||
window.notify('error', 'Fehler beim Laden der Kundenadresse');
|
||||
}
|
||||
},
|
||||
|
||||
show(newVal) {
|
||||
if (newVal) {
|
||||
this.resetForm();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Vue.component('warehouse-offer-modal', {
|
||||
props: {
|
||||
id: {type: [String, Number], required: true},
|
||||
@@ -9,7 +586,7 @@ Vue.component('warehouse-offer-modal', {
|
||||
:delete="id !== 'create'"
|
||||
:title="id === 'create' ? 'Angebot erstellen' : \`Angebot #\${id} bearbeiten\`"
|
||||
@update:show="$emit('close')">
|
||||
<div style="width: 99%"><h4 class="text-center">Angebotdetails</h4>
|
||||
<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
|
||||
@@ -57,9 +634,8 @@ Vue.component('warehouse-offer-modal', {
|
||||
positionsConfig: {
|
||||
fields: {
|
||||
article: {
|
||||
type: 'autocomplete',
|
||||
type: 'input-article',
|
||||
label: 'Artikel',
|
||||
apiUrl: '/WarehouseArticle/autoComplete',
|
||||
customFieldReference: 'WarehouseArticle',
|
||||
},
|
||||
amount: {type: 'input', label: 'Menge', inputType: 'number'},
|
||||
@@ -128,6 +704,9 @@ Vue.component('warehouse-offer-modal', {
|
||||
this.offer = response.data;
|
||||
this.offer.positions = JSON.parse(this.offer.positions);
|
||||
this.offer.alternativePositions = JSON.parse(this.offer.alternativePositions);
|
||||
} else {
|
||||
await this.$nextTick();
|
||||
this.offer.editor = parseInt(window.TT_CONFIG['USER_ID']);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -204,7 +783,7 @@ Vue.component('warehouse-offer-modal', {
|
||||
return total + (position.unitPrice * position.amount) - discount;
|
||||
}, 0);
|
||||
|
||||
return totalPrice - (totalPrice * this.offer.totalDiscount / 100);
|
||||
return totalPrice - (totalPrice * this.offer.totalDiscount / 100).toFixed(2);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -214,6 +793,12 @@ Vue.component('warehouse-offer', {
|
||||
<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"/>
|
||||
|
||||
<div style="display: flex; gap: 8px">
|
||||
<button @click="offerModalId = 'create'" class="btn btn-primary">Angebot erstellen</button>
|
||||
<div class="dropdown">
|
||||
@@ -229,6 +814,11 @@ 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>
|
||||
|
||||
|
||||
@@ -246,6 +836,7 @@ Vue.component('warehouse-offer', {
|
||||
offerModalId: null,
|
||||
offerTemplates: [],
|
||||
offerTemplatesDropdown: false,
|
||||
showBasicModal: false,
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
|
||||
Reference in New Issue
Block a user