Merge branch 'Warehouse/improve' into 'master'
improved new warehouse stuff See merge request fronk/thetool!1539
This commit is contained in:
@@ -310,29 +310,69 @@ $formattedValidUntil = $validUntilDate ? date("d.m.Y", $validUntilDate) : date("
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<?php
|
||||
// --- CALCULATION LOGIC ---
|
||||
|
||||
// Initialize discount variables
|
||||
$discountAmount = 0;
|
||||
$discountPercentage = 0;
|
||||
$subTotalAfterDiscount = $subTotal; // By default, the same as the original subtotal
|
||||
|
||||
// Check if a discount exists and calculate the new amounts
|
||||
if (isset($offer->totalDiscount) && $offer->totalDiscount > 0) {
|
||||
$discountPercentage = $offer->totalDiscount;
|
||||
// Calculate the monetary value of the discount
|
||||
$discountAmount = ($subTotal * $discountPercentage) / 100;
|
||||
// Calculate the subtotal after applying the discount
|
||||
$subTotalAfterDiscount = $subTotal - $discountAmount;
|
||||
}
|
||||
|
||||
// Recalculate VAT and Grand Total based on the potentially discounted subtotal
|
||||
if ($includeTax) {
|
||||
// $vatRate should be a float, e.g., 0.20 for 20%
|
||||
$vatAmount = $subTotalAfterDiscount * $vatRate;
|
||||
$grandTotal = $subTotalAfterDiscount + $vatAmount;
|
||||
} else {
|
||||
$vatAmount = 0; // Ensure VAT is zero if tax is not included
|
||||
$grandTotal = $subTotalAfterDiscount;
|
||||
}
|
||||
|
||||
// --- DISPLAY LOGIC ---
|
||||
?>
|
||||
<table id="summaryTable">
|
||||
<tbody>
|
||||
<tr class="subtotal">
|
||||
<td class="label"><?= $text['summary']['subTotal'] ?>:</td>
|
||||
<td class="value"><?= number_format($subTotal, 2, ',', '.') ?> <?= $currencySymbol ?></td>
|
||||
</tr>
|
||||
<?php if ($includeTax):
|
||||
|
||||
<?php
|
||||
// Display the discount row only if a discount has been applied
|
||||
if ($discountAmount > 0):
|
||||
// Define a label for the discount. You can add 'discount' to your $text array.
|
||||
$discountLabel = $text['summary']['discount'] ?? 'Rabatt';
|
||||
?>
|
||||
<tr>
|
||||
<td class="label"><?= $discountLabel ?> (<?= number_format($discountPercentage, 0) ?>%):</td>
|
||||
<td class="value">-<?= number_format($discountAmount, 2, ',', '.') ?> <?= $currencySymbol ?></td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php
|
||||
// Display the VAT row if tax is included
|
||||
if ($includeTax):
|
||||
$vatLabel = str_replace('{VAT_RATE}', number_format($vatRate * 100, 0), $text['summary']['vatFormatted']);
|
||||
?>
|
||||
<tr>
|
||||
<td class="label"><?= $vatLabel ?>:</td>
|
||||
<td class="value"><?= number_format($vatAmount, 2, ',', '.') ?> <?= $currencySymbol ?></td>
|
||||
</tr>
|
||||
<tr class="grand-total">
|
||||
<td class="label"><?= $text['summary']['total'] ?>:</td>
|
||||
<td class="value"><?= number_format($grandTotal, 2, ',', '.') ?> <?= $currencySymbol ?></td>
|
||||
</tr>
|
||||
<?php else: // If tax not included, Total is same as Subtotal ?>
|
||||
<tr class="grand-total">
|
||||
<td class="label"><?= $text['summary']['total'] ?>:</td>
|
||||
<td class="value"><?= number_format($grandTotal, 2, ',', '.') ?> <?= $currencySymbol ?></td>
|
||||
</tr>
|
||||
<?php endif; ?>
|
||||
|
||||
<tr class="grand-total">
|
||||
<td class="label"><?= $text['summary']['total'] ?>:</td>
|
||||
<td class="value"><?= number_format($grandTotal, 2, ',', '.') ?> <?= $currencySymbol ?></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ class WarehouseOfferModel extends TTCrudBaseModel {
|
||||
public string $reference;
|
||||
public string $customerNumber;
|
||||
public string $customerName;
|
||||
public string $contactPerson;
|
||||
public ?string $contactPerson;
|
||||
public string $customerStreet;
|
||||
public string $customerCity;
|
||||
public string $customerZip;
|
||||
|
||||
@@ -345,31 +345,80 @@ $appendToBody
|
||||
return;
|
||||
}
|
||||
|
||||
$order = WarehouseOrderModel::get($postData['orderId']);
|
||||
$orderAsArray = (array) $order;
|
||||
|
||||
$fullLogMessage = '';
|
||||
|
||||
// 1. Status change message
|
||||
if ($postData['status'] !== 'noChanges') {
|
||||
$statusColumn = array_values(array_filter($this->columns, fn($c) => $c['key'] === 'status'))[0]['modal']['items'];
|
||||
|
||||
$oldStatusKey = array_search($order->status, array_column($statusColumn, 'value'));
|
||||
$newStatusKey = array_search($postData['status'], array_column($statusColumn, 'value'));
|
||||
|
||||
$oldStatusText = ($oldStatusKey !== false) ? $statusColumn[$oldStatusKey]['text'] : $order->status;
|
||||
$newStatusText = ($newStatusKey !== false) ? $statusColumn[$newStatusKey]['text'] : $postData['status'];
|
||||
|
||||
$fullLogMessage .= 'Status wurde geändert von ' . $oldStatusText . ' auf ' . $newStatusText . '.';
|
||||
}
|
||||
|
||||
// 2. Main note from user
|
||||
if (!empty($postData['note'])) {
|
||||
$fullLogMessage .= ($fullLogMessage ? "\n\n" : "") . $postData['note'];
|
||||
}
|
||||
|
||||
// 3. Handle delivery data if present
|
||||
if (isset($postData['deliveryData']) && is_array($postData['deliveryData'])) {
|
||||
$deliveryDetails = [];
|
||||
|
||||
foreach ($postData['deliveryData'] as $delivery) {
|
||||
$orderedAmount = floatval($delivery['orderedAmount']);
|
||||
$deliveredAmount = floatval($delivery['amount']);
|
||||
$articleName = $delivery['articleName'];
|
||||
|
||||
// Only log if there's a discrepancy
|
||||
if ($deliveredAmount < $orderedAmount) {
|
||||
$reasonText = !empty($delivery['reason']) ? " Grund: " . $delivery['reason'] . "." : "";
|
||||
$discrepancyMessage = "Artikel '$articleName': {$deliveredAmount} von {$orderedAmount} Stk. geliefert.{$reasonText}";
|
||||
|
||||
if (isset($delivery['cancelRest']) && $delivery['cancelRest']) {
|
||||
$remaining = $orderedAmount - $deliveredAmount;
|
||||
$discrepancyMessage .= " Restliche {$remaining} Stk. storniert.";
|
||||
}
|
||||
$deliveryDetails[] = $discrepancyMessage;
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($deliveryDetails)) {
|
||||
$fullLogMessage .= ($fullLogMessage ? "\n\n" : "") . "Lieferdetails:\n- " . implode("\n- ", $deliveryDetails);
|
||||
}
|
||||
}
|
||||
|
||||
$log = [
|
||||
"table" => "WarehouseOrder",
|
||||
"rowId" => intval($postData['orderId']),
|
||||
"type" => $postData['status'] === 'noChanges' ? 'noChanges' : 'statusChange',
|
||||
"fileIds" => $postData['fileIds'] ?? null,
|
||||
"message" => $postData['note'] ?? null,
|
||||
"message" => trim($fullLogMessage),
|
||||
"createBy" => intval($this->user->id),
|
||||
"create" => time()
|
||||
];
|
||||
|
||||
try {
|
||||
$order = WarehouseOrderModel::get($log['rowId']);
|
||||
if ($postData['status'] !== 'noChanges') {
|
||||
$oldStatusText = array_values(array_filter($this->columns, fn($c) => $c['key'] === 'status'))[0]['modal']['items'][array_search($order->status, array_column(array_values(array_filter($this->columns, fn($c) => $c['key'] === 'status'))[0]['modal']['items'], 'value'))]['text'];
|
||||
$newStatusText = array_values(array_filter($this->columns, fn($c) => $c['key'] === 'status'))[0]['modal']['items'][array_search($postData['status'], array_column(array_values(array_filter($this->columns, fn($c) => $c['key'] === 'status'))[0]['modal']['items'], 'value'))]['text'];
|
||||
$log['message'] = 'Status wurde geändert von ' . $oldStatusText . ' auf ' . $newStatusText . ($log['message'] ? ': ' . $log['message'] : '');
|
||||
$order->status = $postData['status'];
|
||||
$order = (array) $order;
|
||||
WarehouseOrderModel::update($order);
|
||||
$orderAsArray['status'] = $postData['status'];
|
||||
WarehouseOrderModel::update($orderAsArray);
|
||||
}
|
||||
|
||||
WarehouseLogModel::create($log);
|
||||
self::returnJson(['success' => 'Log entry created']);
|
||||
// Only create a log entry if there's actually something to log
|
||||
if ($postData['status'] !== 'noChanges' || !empty($log['message']) || !empty($log['fileIds'])) {
|
||||
WarehouseLogModel::create($log);
|
||||
}
|
||||
|
||||
self::returnJson(['success' => true, 'message' => 'Log entry created']);
|
||||
} catch (Exception $e) {
|
||||
self::returnJson(['error' => 'Error creating log entry']);
|
||||
self::returnJson(['error' => 'Error creating log entry: ' . $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -638,7 +638,7 @@ Vue.component('warehouse-offer-modal', {
|
||||
label: 'Artikel',
|
||||
customFieldReference: 'WarehouseArticle',
|
||||
},
|
||||
amount: {type: 'input', label: 'Menge', inputType: 'number'},
|
||||
amount: {type: 'input', label: 'Menge', inputType: 'number', editableInTable: true},
|
||||
unit: {type: 'input', label: 'Einheit'},
|
||||
articleNumber: {type: 'input', label: 'Artikelnummer'},
|
||||
isAlternative: {type: 'checkbox', label: 'Alternativposition'},
|
||||
|
||||
@@ -14,20 +14,34 @@ Vue.component('change-status-modal', {
|
||||
sendEmail: false,
|
||||
sendEmailViewedPDF: false,
|
||||
sendEmailMail: '',
|
||||
submitLoading: false
|
||||
submitLoading: false,
|
||||
deliveredPositions: {} // To track delivery details for each position
|
||||
};
|
||||
},
|
||||
async mounted() {
|
||||
const response = await axios.get(`${window.TT_CONFIG["BASE_PATH"]}/WarehouseOrder/getById`, {params: {id: this.orderId}});
|
||||
// if order.status is canceled emit close event and window.notify('error', 'Bestellung wurde storniert')
|
||||
if (response.data.status === 'cancelled') {
|
||||
this.$emit('close');
|
||||
window.notify('error', 'Bestellung wurde storniert');
|
||||
}
|
||||
this.order = response.data;
|
||||
|
||||
// Initialize deliveredPositions after fetching the order
|
||||
if (this.order && this.order.positions) {
|
||||
this.order.positions.forEach((pos, index) => {
|
||||
this.$set(this.deliveredPositions, index, {
|
||||
amount: pos.amount, // Default delivered amount to the ordered amount
|
||||
reason: '',
|
||||
cancelRest: false,
|
||||
articleName: pos.articleName, // Store for easy access in the submit method
|
||||
orderedAmount: pos.amount
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
availableStatuses() {
|
||||
// This computed property remains unchanged
|
||||
switch (this.order.status) {
|
||||
case 'new':
|
||||
return [
|
||||
@@ -73,6 +87,7 @@ Vue.component('change-status-modal', {
|
||||
},
|
||||
methods: {
|
||||
async handleFileUpload(event) {
|
||||
// This method remains unchanged
|
||||
const files = event.target.files;
|
||||
if (!files.length) return;
|
||||
|
||||
@@ -101,11 +116,11 @@ Vue.component('change-status-modal', {
|
||||
window.notify('error', `Error uploading file "${file.name}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the file input
|
||||
event.target.value = '';
|
||||
},
|
||||
removeFile: index => this.uploadedFiles.splice(index, 1),
|
||||
removeFile(index) {
|
||||
this.uploadedFiles.splice(index, 1)
|
||||
},
|
||||
async submit() {
|
||||
this.submitLoading = true;
|
||||
if (this.newStatus === 'accepted' && this.sendEmail && !this.sendEmailMail) {
|
||||
@@ -124,11 +139,19 @@ Vue.component('change-status-modal', {
|
||||
}
|
||||
|
||||
const fileIds = this.uploadedFiles.map(file => file.id);
|
||||
|
||||
// Prepare delivery data if the status is related to delivery
|
||||
let deliveryData = null;
|
||||
if (this.newStatus === 'partiallyDelivered' || this.newStatus === 'fullyDelivered') {
|
||||
deliveryData = this.deliveredPositions;
|
||||
}
|
||||
|
||||
const response = await axios.post(`${window.TT_CONFIG["BASE_PATH"]}/WarehouseOrder/createNewLogAction`, {
|
||||
orderId: this.order.id,
|
||||
status: this.newStatus,
|
||||
note: this.note,
|
||||
fileIds: JSON.stringify(fileIds),
|
||||
deliveryData: deliveryData // Send the new delivery data to the backend
|
||||
});
|
||||
|
||||
if (response.data.success) {
|
||||
@@ -141,6 +164,7 @@ Vue.component('change-status-modal', {
|
||||
this.submitLoading = false;
|
||||
},
|
||||
async generateShippingNote() {
|
||||
// This method remains unchanged
|
||||
const positions = this.order.positions.map(position => ({
|
||||
article: position.article,
|
||||
amount: position.amount,
|
||||
@@ -165,6 +189,7 @@ Vue.component('change-status-modal', {
|
||||
return response.data.id;
|
||||
},
|
||||
async submitEmail(shippingNote = null) {
|
||||
// This method remains unchanged
|
||||
const response = await axios.get(`${window.TT_CONFIG["BASE_PATH"]}/WarehouseOrder/sendEmail?id=${this.order.id}&email=${this.sendEmailMail}${shippingNote ? '&shippingNote=' + shippingNote : ''}`);
|
||||
|
||||
if (response.data.success) {
|
||||
@@ -175,82 +200,111 @@ Vue.component('change-status-modal', {
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<tt-modal :show="true" :delete="false" @submit="submit" @update:show="$emit('close')" title="Status ändern" :save-loading="submitLoading">
|
||||
<tt-loader :absolute="false" v-if="!order"/>
|
||||
<template v-else>
|
||||
<tt-select label="Neuer Status" v-model="newStatus" :options="availableStatuses" sm row/>
|
||||
<tt-modal :show="true" :delete="false" @submit="submit" @update:show="$emit('close')" title="Status ändern" :save-loading="submitLoading">
|
||||
<tt-loader :absolute="false" v-if="!order"/>
|
||||
<template v-else>
|
||||
<tt-select label="Neuer Status" v-model="newStatus" :options="availableStatuses" sm row/>
|
||||
|
||||
<div class="form-group" style="margin: 10px 0">
|
||||
<label>Dateiupload (Mehrere)</label>
|
||||
<input type="file" class="form-control" @change="handleFileUpload" multiple/>
|
||||
</div>
|
||||
<div class="form-group" style="margin: 10px 0">
|
||||
<label>Dateiupload (Mehrere)</label>
|
||||
<input type="file" class="form-control" @change="handleFileUpload" multiple/>
|
||||
</div>
|
||||
|
||||
<div v-if="uploadedFiles.length" class="upload-success-alert">
|
||||
<div class="alert-header">
|
||||
<i class="fa fa-check-circle" aria-hidden="true"></i>
|
||||
<span v-if="uploadedFiles.length === 1">Datei erfolgreich hochgeladen</span>
|
||||
<span v-else>Dateien erfolgreich hochgeladen</span>
|
||||
</div>
|
||||
<ul class="file-list">
|
||||
<li v-for="(file, index) in uploadedFiles" :key="file.id" class="file-item">
|
||||
<i class="fa fa-file" aria-hidden="true"></i>
|
||||
<span class="file-name">{{ file.name }}</span>
|
||||
<button type="button" class="remove-btn" @click="removeFile(index)">
|
||||
<i class="fa fa-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div v-if="uploadedFiles.length" class="upload-success-alert">
|
||||
<div class="alert-header">
|
||||
<i class="fa fa-check-circle" aria-hidden="true"></i>
|
||||
<span v-if="uploadedFiles.length === 1">Datei erfolgreich hochgeladen</span>
|
||||
<span v-else>Dateien erfolgreich hochgeladen</span>
|
||||
</div>
|
||||
<ul class="file-list">
|
||||
<li v-for="(file, index) in uploadedFiles" :key="file.id" class="file-item">
|
||||
<i class="fa fa-file" aria-hidden="true"></i>
|
||||
<span class="file-name">{{ file.name }}</span>
|
||||
<button type="button" class="remove-btn" @click="removeFile(index)">
|
||||
<i class="fa fa-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<tt-textarea label="Bemerkung" v-model="note" sm/>
|
||||
|
||||
<tt-textarea label="Bemerkung" v-model="note" sm/>
|
||||
<div v-if="newStatus === 'partiallyDelivered' || newStatus === 'fullyDelivered'">
|
||||
<h4 class="mt-3">Positionen Lieferung erfassen</h4>
|
||||
<div style="display: grid; grid-template-columns: 2fr 1fr 1fr 2fr 1fr; grid-gap: 10px; font-weight: bold; margin-bottom: 10px; padding-bottom: 5px; border-bottom: 1px solid #ccc;">
|
||||
<div>Artikel</div>
|
||||
<div>Bestellt</div>
|
||||
<div>Geliefert</div>
|
||||
<div>Grund für Abweichung</div>
|
||||
<div class="text-center">Rest stornieren</div>
|
||||
</div>
|
||||
<template v-for="(position, index) in order.positions">
|
||||
<div :key="index" style="display: grid; grid-template-columns: 2fr 1fr 1fr 2fr 1fr; grid-gap: 10px; align-items: center; margin-bottom: 15px;">
|
||||
<div>{{ position.articleName }}</div>
|
||||
<div class="text-center">{{ position.amount }}</div>
|
||||
<div>
|
||||
<tt-input type="number"
|
||||
:max="position.amount"
|
||||
min="0"
|
||||
v-model.number="deliveredPositions[index].amount"
|
||||
sm
|
||||
no-form-group
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<tt-input type="text"
|
||||
v-if="deliveredPositions[index].amount < position.amount"
|
||||
v-model="deliveredPositions[index].reason"
|
||||
placeholder="z.B. Lieferschaden"
|
||||
sm
|
||||
no-form-group
|
||||
/>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<input type="checkbox"
|
||||
v-if="deliveredPositions[index].amount < position.amount && deliveredPositions[index].amount !== ''"
|
||||
v-model="deliveredPositions[index].cancelRest"
|
||||
class="form-check-input"
|
||||
style="position: relative; margin-left: auto; margin-right: auto;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="newStatus === 'partiallyDelivered' || newStatus === 'fullyDelivered'">
|
||||
<h4>Positionen</h4>
|
||||
<div style="display: grid; grid-gap: 10px; grid-template-columns: 1fr 1fr 1fr;margin-top: 24px">
|
||||
<div><strong>Artikel</strong></div>
|
||||
<div><strong>Menge</strong></div>
|
||||
<div><strong>Geliefert?</strong></div>
|
||||
<template v-for="position in order.positions">
|
||||
<div>{{ position.articleName }}</div>
|
||||
<div>{{ position.amount }}</div>
|
||||
<div><input type="checkbox" v-model="position.delivered"/></div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="newStatus === 'accepted'">
|
||||
<h4>E-Mail verschicken?</h4>
|
||||
|
||||
<div v-if="newStatus === 'accepted'">
|
||||
<h4>E-Mail verschicken?</h4>
|
||||
<tt-button
|
||||
additional-class="btn-outline-primary"
|
||||
icon="fa fa-file-pdf text-danger"
|
||||
text="PDF anzeigen"
|
||||
@click="sendEmailViewedPDF = true;window.open(window.TT_CONFIG.BASE_PATH + '/WarehouseOrder/createPDF?id=' + order.id)"
|
||||
></tt-button>
|
||||
|
||||
<tt-button
|
||||
additional-class="btn-outline-primary"
|
||||
icon="fa fa-file-pdf text-danger"
|
||||
text="PDF anzeigen"
|
||||
@click="sendEmailViewedPDF = true;window.open(window.TT_CONFIG.BASE_PATH + '/WarehouseOrder/createPDF?id=' + order.id)"
|
||||
></tt-button>
|
||||
|
||||
<div class="mt-2 d-flex align-items-center">
|
||||
<div class="mr-2">E-Mail senden:</div>
|
||||
<label class="ios-switch-wrapper" :class="{'disabled': !sendEmailViewedPDF}">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="sendEmail"
|
||||
:disabled="!sendEmailViewedPDF"
|
||||
>
|
||||
<span class="ios-switch-slider"></span>
|
||||
</label>
|
||||
<div v-if="!sendEmailViewedPDF" class="text-muted ml-2 text-danger">
|
||||
Bitte erst PDF ansehen
|
||||
</div>
|
||||
</div>
|
||||
<tt-input v-if="sendEmail" label="E-Mail-Adresse" v-model="sendEmailMail" sm row/>
|
||||
</div>
|
||||
</template>
|
||||
</tt-modal>
|
||||
|
||||
`
|
||||
<div class="mt-2 d-flex align-items-center">
|
||||
<div class="mr-2">E-Mail senden:</div>
|
||||
<label class="ios-switch-wrapper" :class="{'disabled': !sendEmailViewedPDF}">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="sendEmail"
|
||||
:disabled="!sendEmailViewedPDF"
|
||||
>
|
||||
<span class="ios-switch-slider"></span>
|
||||
</label>
|
||||
<div v-if="!sendEmailViewedPDF" class="text-muted ml-2 text-danger">
|
||||
Bitte erst PDF ansehen
|
||||
</div>
|
||||
</div>
|
||||
<tt-input v-if="sendEmail" label="E-Mail-Adresse" v-model="sendEmailMail" sm row/>
|
||||
</div>
|
||||
</template>
|
||||
</tt-modal>
|
||||
`
|
||||
});
|
||||
|
||||
// The other components in WarehouseOrder.js (warehouse-order-modal, tt-file, etc.) remain unchanged.
|
||||
// I am including them here to provide the full file content.
|
||||
Vue.component('warehouse-order-modal', {
|
||||
props: {
|
||||
id: {type: [String, Number], required: true},
|
||||
@@ -527,48 +581,46 @@ Vue.component('tt-file', {
|
||||
|
||||
Vue.component('warehouse-order-detail', {
|
||||
template: `
|
||||
<tt-card>
|
||||
<template v-slot:header><h4>Bestellungsdetails für #{{ loading ? 'Laden...' : order.orderNumber }}</h4></template>
|
||||
<template v-if="loading">
|
||||
<div class="d-flex justify-content-center align-items-center">
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status"><span class="sr-only">Loading...</span></div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<h3>Positionen</h3>
|
||||
<div class="grid-container header">
|
||||
<div v-for="header in ['Artikel', 'Menge', 'Preis', 'Lieferant', 'Verwendung', 'Summe']"><strong>{{ header }}</strong></div>
|
||||
</div>
|
||||
<div class="grid-container" v-for="p in order.positions">
|
||||
<div>{{ p.articleName }}</div>
|
||||
<div>{{ p.amount }}</div>
|
||||
<div>{{ p.buyPrice }}</div>
|
||||
<div>{{ p.distributorName }}</div>
|
||||
<div>{{ p.verwendung }}</div>
|
||||
<div>{{ p.amount * p.buyPrice }}</div>
|
||||
</div>
|
||||
<template v-if="orderLog?.length > 0">
|
||||
<hr>
|
||||
<h3>Log</h3>
|
||||
<div v-for="log in orderLog">
|
||||
{{ formatDate(log.create) }} ({{ getUserName(log.createBy) }}) | {{ log.message }}
|
||||
<!-- if log.fileIds exists and it is a array of ids use <tt-file :id=> to show the file-->
|
||||
<tt-card>
|
||||
<template v-slot:header><h4>Bestellungsdetails für #{{ loading ? 'Laden...' : order.orderNumber }}</h4></template>
|
||||
<template v-if="loading">
|
||||
<div class="d-flex justify-content-center align-items-center">
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status"><span class="sr-only">Loading...</span></div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<h3>Positionen</h3>
|
||||
<div class="grid-container header">
|
||||
<div v-for="header in ['Artikel', 'Menge', 'Preis', 'Lieferant', 'Verwendung', 'Summe']"><strong>{{ header }}</strong></div>
|
||||
</div>
|
||||
<div class="grid-container" v-for="p in order.positions">
|
||||
<div>{{ p.articleName }}</div>
|
||||
<div>{{ p.amount }}</div>
|
||||
<div>{{ p.buyPrice }}</div>
|
||||
<div>{{ p.distributorName }}</div>
|
||||
<div>{{ p.verwendung }}</div>
|
||||
<div>{{ p.amount * p.buyPrice }}</div>
|
||||
</div>
|
||||
<template v-if="orderLog?.length > 0">
|
||||
<hr>
|
||||
<h3>Log</h3>
|
||||
<div v-for="log in orderLog">
|
||||
{{ formatDate(log.create) }} ({{ getUserName(log.createBy) }}) | {{ log.message }}
|
||||
<template v-if="log.fileIds">
|
||||
<div v-for="file in JSON.parse(log.fileIds)">
|
||||
<tt-file :id="file"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="log.fileIds">
|
||||
<div v-for="file in JSON.parse(log.fileIds)">
|
||||
<tt-file :id="file"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
<hr>
|
||||
<h3>Lieferadresse</h3>
|
||||
<div v-for="field in ['delAddrName', 'delAddrEMail', 'delAddrLine']">{{ order[field] }}</div>
|
||||
<div>{{ order.delAddrPLZ }} {{ order.delAddrCity }}</div>
|
||||
</template>
|
||||
</tt-card>
|
||||
`,
|
||||
</div>
|
||||
</template>
|
||||
<hr>
|
||||
<h3>Lieferadresse</h3>
|
||||
<div v-for="field in ['delAddrName', 'delAddrEMail', 'delAddrLine']">{{ order[field] }}</div>
|
||||
<div>{{ order.delAddrPLZ }} {{ order.delAddrCity }}</div>
|
||||
</template>
|
||||
</tt-card>
|
||||
`,
|
||||
props: ['id'],
|
||||
data: () => ({order: {}, orderLog: null, loading: true}),
|
||||
async mounted() {
|
||||
@@ -600,7 +652,8 @@ Vue.component('warehouse-order', {
|
||||
<warehouse-order-detail :id="row.id"/>
|
||||
</template>
|
||||
<template v-slot:sum="{ row }">{{ calculateSum(JSON.parse(row["positions"])).toFixed(2) }} €</template>
|
||||
</tt-table-crud> </tt-card>
|
||||
</tt-table-crud>
|
||||
</tt-card>
|
||||
`,
|
||||
data: () => ({
|
||||
orderModalId: null,
|
||||
@@ -619,4 +672,4 @@ Vue.component('warehouse-order', {
|
||||
calculateSum: positions => positions.reduce((sum, {amount, buyPrice}) => sum + amount * buyPrice, 0),
|
||||
openPDF: order => window.open(`${window.TT_CONFIG["BASE_PATH"]}/WarehouseOrder/createPDF?id=${order.id}`)
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -4,18 +4,13 @@ Vue.component('tt-resolver', {
|
||||
reference: { type: String, required: true },
|
||||
autocomplete: { type: Boolean, default: false }
|
||||
},
|
||||
data: () => ({
|
||||
loading: true,
|
||||
text: ''
|
||||
}),
|
||||
template: `
|
||||
<div v-if="loading" class="d-flex justify-content-center align-items-center">
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status">
|
||||
<span class="sr-only">Loading...</span>
|
||||
</div>
|
||||
data: () => ({loading: true, text: ''}),
|
||||
template: `<div v-if="loading" class="d-flex justify-content-center align-items-center">
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status">
|
||||
<span class="sr-only">Loading...</span>
|
||||
</div>
|
||||
<span v-else>{{ text }}</span>
|
||||
`,
|
||||
</div>
|
||||
<span v-else>{{ text }}</span>`,
|
||||
async created() {
|
||||
const cacheKey = `${this.reference}_${this.value}_${this.autocomplete}`;
|
||||
const cached = JSON.parse(localStorage.getItem(cacheKey) || 'null');
|
||||
@@ -40,360 +35,417 @@ Vue.component('tt-resolver', {
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Vue.component('tt-positions-manager',
|
||||
{
|
||||
props: {
|
||||
value: {type: [Array, String], required: false},
|
||||
config: {type: Object, required: true},
|
||||
groupMode: {type: Boolean, default: false},
|
||||
submitLoading: {type: Boolean, default: false}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
window: window,
|
||||
positions: this.value,
|
||||
formData: {},
|
||||
groupName: '',
|
||||
selectedIndex: null,
|
||||
editingGroupName: null,
|
||||
tempGroupName: '',
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="positions-manager">
|
||||
<template v-if="config['header']">
|
||||
<h4 class="text-center">{{ config["header"] }}</h4>
|
||||
</template>
|
||||
<div class="form-container">
|
||||
<template v-for="(field, key) in config.fields">
|
||||
<template v-if="typeof field.showCondition === 'function' ? field.showCondition(formData) : true">
|
||||
<slot :name="key" v-bind:field="field" v-bind:value="formData[key]">
|
||||
<div :style="field.style ?? {}" :key="key + field.label">
|
||||
<tt-input
|
||||
v-if="field.type === 'input'"
|
||||
:label="field.label"
|
||||
v-model="formData[key]"
|
||||
@input="$emit('updateField-' + key, $event)"
|
||||
sm
|
||||
:type="field.inputType || 'text'"
|
||||
/>
|
||||
<tt-autocomplete
|
||||
v-else-if="field.type === 'autocomplete'"
|
||||
:label="field.label"
|
||||
:emit-display-value="field.emitDisplayValue || false"
|
||||
v-model="formData[key]"
|
||||
@input="$emit('updateField-' + key, $event)"
|
||||
@displayValue="$emit('updateField-' + key + '_text', $event)"
|
||||
:ref="'autocomplete-' + key"
|
||||
:api-url="window.TT_CONFIG['BASE_PATH'] + field.apiUrl"
|
||||
sm
|
||||
/>
|
||||
<tt-input-article
|
||||
v-if="field.type === 'input-article'"
|
||||
:label="field.label"
|
||||
:emit-display-value="field.emitDisplayValue || false"
|
||||
v-model="formData[key]"
|
||||
@input="$emit('updateField-' + key, $event)"
|
||||
@displayValue="$emit('updateField-' + key + '_text', $event)"
|
||||
:ref="'article-' + key"
|
||||
:api-url="field.apiUrl"
|
||||
:field="field"
|
||||
sm
|
||||
/>
|
||||
<tt-textarea
|
||||
v-else-if="field.type === 'textarea'"
|
||||
:label="field.label"
|
||||
v-model="formData[key]"
|
||||
@input="$emit('updateField-' + key, $event)"
|
||||
sm
|
||||
/>
|
||||
<tt-checkbox
|
||||
v-else-if="field.type === 'checkbox'"
|
||||
:label="field.label"
|
||||
@input="$emit('updateField-' + key, $event)"
|
||||
sm
|
||||
v-model="formData[key]"
|
||||
/>
|
||||
<tt-select
|
||||
v-else-if="field.type === 'select'"
|
||||
:label="field.label"
|
||||
@input="$emit('updateField-' + key, $event)"
|
||||
sm
|
||||
v-model="formData[key]"
|
||||
:options="field.options"
|
||||
/>
|
||||
<slot :name="key + '-prepend'" v-bind:field="field" v-bind:value="formData[key]"/>
|
||||
</div>
|
||||
|
||||
</slot>
|
||||
</template>
|
||||
</template>
|
||||
<div class="button-wrapper">
|
||||
<tt-button @click="saveEntry"
|
||||
sm
|
||||
:loading="submitLoading"
|
||||
:additional-class="selectedIndex === null ? 'btn-primary' : 'btn-success'"
|
||||
:text="selectedIndex === null ? 'Hinzufügen' : 'Aktualisieren'"/>
|
||||
</div>
|
||||
|
||||
<slot name="form-actions-append"></slot>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="form-container" v-if="groupMode">
|
||||
<tt-input label="Gruppenname" v-model="groupName" sm/>
|
||||
<tt-button @click="addGroup" sm text="Gruppe hinzufügen" additional-class="btn-primary"/>
|
||||
</div>
|
||||
|
||||
<table class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="field in config['fields']">{{ field.label }}</th>
|
||||
<th style="text-align: right;padding-right: 24px">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
<template v-for="(group, groupName) in positionsToRender">
|
||||
<tr v-if="groupMode">
|
||||
<td :colspan="Object.keys(config.fields).length + 1">
|
||||
<div class="d-flex justify-content-center align-items-center">
|
||||
<template v-if="editingGroupName === groupName">
|
||||
<tt-input v-model="tempGroupName" sm style="max-width: 200px; margin-right: 10px;"/>
|
||||
<tt-button @click="saveGroupName(groupName)" sm additional-class="btn-success" icon="fa fa-check" style="margin-right: 5px;"/>
|
||||
<tt-button @click="cancelGroupEdit" sm additional-class="btn-secondary" icon="fa fa-times"/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<h4 style="margin: 0; margin-right: 10px;">{{ groupName }}</h4>
|
||||
<template v-if="groupName !== 'Keine Gruppe'">
|
||||
<tt-button @click="startGroupEdit(groupName)" sm additional-class="btn-primary" icon="fa fa-edit" style="margin-right: 5px;"/>
|
||||
<tt-button @click="deleteGroup(groupName)" sm additional-class="btn-danger" icon="fa fa-trash"/>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-for="(position, index) in group" :key="groupMode ? groupName + index : index">
|
||||
<td v-for="(field, key) in config.fields">
|
||||
<tt-resolver
|
||||
:key="index + key + position[key]"
|
||||
v-if="position[key] && (field.customFieldReference || field.type === 'autocomplete')"
|
||||
:autocomplete="!field.customFieldReference && field.type === 'autocomplete'"
|
||||
:reference="field.customFieldReference || field.apiUrl"
|
||||
:value="position[key]"
|
||||
/>
|
||||
<span v-else-if="field.type === 'checkbox'">{{ position[key] ? 'Ja' : 'Nein' }}</span>
|
||||
<span v-else>{{ formatFieldValue(position[key] ?? position[key + '_text'], field) }}</span>
|
||||
</td>
|
||||
<td class="d-flex justify-content-end">
|
||||
<select v-if="groupMode" v-model="position._group" @change="$set(position, '_group', $event.target.value)">
|
||||
<option v-for="group in allGroups" :value="group">{{ group }}</option>
|
||||
</select>
|
||||
<tt-button @click="editEntry(getActualIndex(position, groupName, index))" sm additional-class="btn-primary" icon="fa fa-edit"/>
|
||||
<tt-button @click="deleteEntry(getActualIndex(position, groupName, index))" sm additional-class="btn-danger" icon="fa fa-trash"/>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`,
|
||||
methods: {
|
||||
updateField(key, value) {
|
||||
this.$set(this.formData, key, value);
|
||||
},
|
||||
defaultValidateForm(formData) {
|
||||
for (const field of this.config["validateFormOptions"]) {
|
||||
if (!(formData[field.key] || formData[field.key + '_text'])) {
|
||||
window.notify('error', field.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
checkEmitDisplayValueAutocomplete() {
|
||||
for (const [key, field] of Object.entries(this.config.fields)) {
|
||||
if ((typeof field.showCondition === 'function' && field.showCondition(this.formData) === true || !field.showCondition) && field.type === 'autocomplete' && field.emitDisplayValue && (isNaN(this.formData[key]) || !this.formData[key]) && this.$refs['autocomplete-' + key][0]) {
|
||||
this.$set(this.formData, key + '_text', this.$refs['autocomplete-' + key][0].displayValue);
|
||||
this.$delete(this.formData, key);
|
||||
}
|
||||
|
||||
if ((typeof field.showCondition === 'function' && field.showCondition(this.formData) === true || !field.showCondition) && field.type === 'input-article' && field.emitDisplayValue && (isNaN(this.formData[key]) || !this.formData[key]) && this.$refs['article-' + key][0]) {
|
||||
console.log(this.$refs['article-' + key][0].$refs.autocomplete);
|
||||
this.$set(this.formData, key + '_text', this.$refs['article-' + key][0].$refs.autocomplete.displayValue);
|
||||
this.$delete(this.formData, key);
|
||||
}
|
||||
|
||||
if (field.emitDisplayValue && this.formData[key] !== null && this.formData[key] !== undefined) {
|
||||
this.$delete(this.formData, key + '_text');
|
||||
}
|
||||
|
||||
}
|
||||
},
|
||||
async saveEntry() {
|
||||
this.checkEmitDisplayValueAutocomplete();
|
||||
if (this.config.hasOwnProperty('validateFormOptions') && !this.defaultValidateForm(this.formData)) return;
|
||||
else if (this.config.validateForm && !await this.config.validateForm(this.formData)) return;
|
||||
|
||||
if (this.selectedIndex === null) this.positions.push(this.formData);
|
||||
else this.$set(this.positions, this.selectedIndex, this.formData);
|
||||
|
||||
if (this.config.customOrdering) {
|
||||
this.positions.sort((a, b) => a[this.config.customOrdering] - b[this.config.customOrdering]);
|
||||
}
|
||||
|
||||
this.$emit('input', this.positions);
|
||||
this.resetForm();
|
||||
},
|
||||
addGroup() {
|
||||
this.positions.push({_group: this.groupName});
|
||||
this.groupName = '';
|
||||
},
|
||||
startGroupEdit(groupName) {
|
||||
this.editingGroupName = groupName;
|
||||
this.tempGroupName = groupName;
|
||||
},
|
||||
saveGroupName(oldGroupName) {
|
||||
if (this.tempGroupName.trim() === '') {
|
||||
window.notify('error', 'Gruppenname darf nicht leer sein');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.tempGroupName !== oldGroupName) {
|
||||
// Update all positions with the old group name to the new group name
|
||||
this.positions.forEach(position => {
|
||||
if (position._group === oldGroupName) {
|
||||
this.$set(position, '_group', this.tempGroupName);
|
||||
}
|
||||
});
|
||||
this.$emit('input', this.positions);
|
||||
}
|
||||
|
||||
this.editingGroupName = null;
|
||||
this.tempGroupName = '';
|
||||
},
|
||||
cancelGroupEdit() {
|
||||
this.editingGroupName = null;
|
||||
this.tempGroupName = '';
|
||||
},
|
||||
deleteGroup(groupName) {
|
||||
if (confirm(`Möchten Sie die Gruppe "${groupName}" wirklich löschen? Alle Einträge werden in "Keine Gruppe" verschoben.`)) {
|
||||
// Move all items from this group to "Keine Gruppe" and remove empty group entries
|
||||
this.positions = this.positions.filter(position => {
|
||||
if (position._group === groupName) {
|
||||
// If it's just a group header (only has _group property), remove it
|
||||
if (Object.keys(position).length === 1) {
|
||||
return false;
|
||||
}
|
||||
// Otherwise, move to "Keine Gruppe"
|
||||
this.$set(position, '_group', 'Keine Gruppe');
|
||||
}
|
||||
return true;
|
||||
});
|
||||
this.$emit('input', this.positions);
|
||||
}
|
||||
},
|
||||
getActualIndex(position, groupName, groupIndex) {
|
||||
// Find the actual index in the positions array
|
||||
if (!this.groupMode) return groupIndex;
|
||||
|
||||
let actualIndex = 0;
|
||||
let currentGroupIndex = 0;
|
||||
|
||||
for (let i = 0; i < this.positions.length; i++) {
|
||||
const pos = this.positions[i];
|
||||
const posGroup = pos._group ?? 'Keine Gruppe';
|
||||
|
||||
if (posGroup === groupName && Object.keys(pos).length > 1) {
|
||||
if (currentGroupIndex === groupIndex) {
|
||||
return i;
|
||||
}
|
||||
currentGroupIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
return groupIndex; // Fallback
|
||||
},
|
||||
async editEntry(index) {
|
||||
this.selectedIndex = index;
|
||||
for (const [key, field] of Object.entries(this.config.fields)) {
|
||||
if (field.type === 'autocomplete' && field.emitDisplayValue) {
|
||||
await this.$nextTick();
|
||||
if (this.positions[index][key + '_text'] && this.$refs['autocomplete-' + key][0]) {
|
||||
console.log('inhere');
|
||||
this.$refs['autocomplete-' + key][0].displayValue = this.positions[index][key + '_text'];
|
||||
this.$set(this.formData, key, this.positions[index][key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.formData = {...this.positions[index]};
|
||||
},
|
||||
deleteEntry(index) {
|
||||
this.positions.splice(index, 1);
|
||||
this.$emit('input', this.positions);
|
||||
},
|
||||
resetForm() {
|
||||
this.formData = {};
|
||||
for (const [key, field] of Object.entries(this.config.fields)) {
|
||||
if (
|
||||
typeof field.showCondition === 'function' && field.showCondition(this.formData) === true &&
|
||||
field.type === 'autocomplete' && field.emitDisplayValue && this.$refs['autocomplete-' + key][0]) {
|
||||
this.$refs['autocomplete-' + key][0].displayValue = '';
|
||||
}
|
||||
if (field.inputType === 'date') {
|
||||
this.$set(this.formData, key, moment().format('YYYY-MM-DD'));
|
||||
}
|
||||
}
|
||||
this.selectedIndex = null;
|
||||
},
|
||||
formatFieldValue(value, field) {
|
||||
if (field.formatter) return field.formatter(value);
|
||||
if (field.inputType === 'date') return moment(value).format('DD.MM.YYYY');
|
||||
return value;
|
||||
},
|
||||
},
|
||||
//TODO: cleanup
|
||||
created() {
|
||||
if (this.config["customMethods"]) Object.assign(this, this.config.customMethods);
|
||||
if (!this.positions) this.positions = [];
|
||||
if (typeof this.positions === 'string') {
|
||||
try {
|
||||
this.positions = JSON.parse(this.positions);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
this.positions = [];
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.resetForm();
|
||||
},
|
||||
computed: {
|
||||
groupedPositions() {
|
||||
const groups = {};
|
||||
for (const position of this.positions) {
|
||||
const group = position._group ?? 'Keine Gruppe';
|
||||
if (!groups[group]) groups[group] = [];
|
||||
if (Object.keys(position).length !== 1) groups[group].push(position);
|
||||
}
|
||||
return groups;
|
||||
},
|
||||
positionsToRender() {
|
||||
return this.groupMode ? this.groupedPositions : {'': this.positions};
|
||||
},
|
||||
allGroups() {
|
||||
return Object.keys(this.groupedPositions);
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
value: {
|
||||
handler() {
|
||||
this.positions = this.value;
|
||||
},
|
||||
deep: true
|
||||
Vue.component('tt-positions-manager', {
|
||||
props: {
|
||||
value: {type: [Array, String], required: false},
|
||||
config: {type: Object, required: true},
|
||||
groupMode: {type: Boolean, default: false},
|
||||
submitLoading: {type: Boolean, default: false}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
window: window,
|
||||
positions: this.value,
|
||||
formData: {},
|
||||
groupName: '',
|
||||
selectedIndex: null,
|
||||
editingGroupName: null,
|
||||
tempGroupName: '',
|
||||
// New state for instant editing
|
||||
editingCell: {
|
||||
groupName: null, // For grouped positions
|
||||
index: null, // Index within the group or flat array
|
||||
key: null // Field key being edited
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
template: `
|
||||
<div class="positions-manager">
|
||||
<template v-if="config['header']">
|
||||
<h4 class="text-center">{{ config["header"] }}</h4>
|
||||
</template>
|
||||
<div class="form-container" ref="formContainer">
|
||||
<template v-for="(field, key) in config.fields">
|
||||
<template v-if="typeof field.showCondition === 'function' ? field.showCondition(formData) : true">
|
||||
<slot :name="key" v-bind:field="field" v-bind:value="formData[key]">
|
||||
<div :style="field.style ?? {}" :key="key + field.label">
|
||||
<tt-input
|
||||
v-if="field.type === 'input'"
|
||||
:label="field.label"
|
||||
v-model="formData[key]"
|
||||
@input="$emit('updateField-' + key, $event)"
|
||||
sm
|
||||
:type="field.inputType || 'text'"
|
||||
/>
|
||||
<tt-autocomplete
|
||||
v-else-if="field.type === 'autocomplete'"
|
||||
:label="field.label"
|
||||
:emit-display-value="field.emitDisplayValue || false"
|
||||
v-model="formData[key]"
|
||||
@input="$emit('updateField-' + key, $event)"
|
||||
@displayValue="$emit('updateField-' + key + '_text', $event)"
|
||||
:ref="'autocomplete-' + key"
|
||||
:api-url="window.TT_CONFIG['BASE_PATH'] + field.apiUrl"
|
||||
sm
|
||||
/>
|
||||
<tt-input-article
|
||||
v-if="field.type === 'input-article'"
|
||||
:label="field.label"
|
||||
:emit-display-value="field.emitDisplayValue || false"
|
||||
v-model="formData[key]"
|
||||
@input="$emit('updateField-' + key, $event)"
|
||||
@displayValue="$emit('updateField-' + key + '_text', $event)"
|
||||
:ref="'article-' + key"
|
||||
:api-url="field.apiUrl"
|
||||
:field="field"
|
||||
sm
|
||||
/>
|
||||
<tt-textarea
|
||||
v-else-if="field.type === 'textarea'"
|
||||
:label="field.label"
|
||||
v-model="formData[key]"
|
||||
@input="$emit('updateField-' + key, $event)"
|
||||
sm
|
||||
/>
|
||||
<tt-checkbox
|
||||
v-else-if="field.type === 'checkbox'"
|
||||
:label="field.label"
|
||||
@input="$emit('updateField-' + key, $event)"
|
||||
sm
|
||||
v-model="formData[key]"
|
||||
/>
|
||||
<tt-select
|
||||
v-else-if="field.type === 'select'"
|
||||
:label="field.label"
|
||||
@input="$emit('updateField-' + key, $event)"
|
||||
sm
|
||||
v-model="formData[key]"
|
||||
:options="field.options"
|
||||
/>
|
||||
<slot :name="key + '-prepend'" v-bind:field="field" v-bind:value="formData[key]"/>
|
||||
</div>
|
||||
|
||||
</slot>
|
||||
</template>
|
||||
</template>
|
||||
<div class="button-wrapper">
|
||||
<tt-button @click="saveEntry"
|
||||
sm
|
||||
:loading="submitLoading"
|
||||
:additional-class="selectedIndex === null ? 'btn-primary' : 'btn-success'"
|
||||
:text="selectedIndex === null ? 'Hinzufügen' : 'Aktualisieren'"/>
|
||||
</div>
|
||||
|
||||
<slot name="form-actions-append"></slot>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="form-container" v-if="groupMode">
|
||||
<tt-input label="Gruppenname" v-model="groupName" sm/>
|
||||
<tt-button @click="addGroup" sm text="Gruppe hinzufügen" additional-class="btn-primary"/>
|
||||
</div>
|
||||
|
||||
<table class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th v-for="field in config['fields']">{{ field.label }}</th>
|
||||
<th style="text-align: right;padding-right: 24px">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
<template v-for="(group, groupName) in positionsToRender">
|
||||
<tr v-if="groupMode">
|
||||
<td :colspan="Object.keys(config.fields).length + 1">
|
||||
<div class="d-flex justify-content-center align-items-center">
|
||||
<template v-if="editingGroupName === groupName">
|
||||
<tt-input v-model="tempGroupName" sm style="max-width: 200px; margin-right: 10px;"/>
|
||||
<tt-button @click="saveGroupName(groupName)" sm additional-class="btn-success" icon="fa fa-check" style="margin-right: 5px;"/>
|
||||
<tt-button @click="cancelGroupEdit" sm additional-class="btn-secondary" icon="fa fa-times"/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<h4 style="margin: 0; margin-right: 10px;">{{ groupName }}</h4>
|
||||
<template v-if="groupName !== 'Keine Gruppe'">
|
||||
<tt-button @click="startGroupEdit(groupName)" sm additional-class="btn-primary" icon="fa fa-edit" style="margin-right: 5px;"/>
|
||||
<tt-button @click="deleteGroup(groupName)" sm additional-class="btn-danger" icon="fa fa-trash"/>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-for="(position, index) in group" :key="groupMode ? groupName + index : index">
|
||||
<td v-for="(field, key) in config.fields"
|
||||
:class="{'editable-cell': field.editableInTable && (field.type === 'input' || field.type === 'textarea')}"
|
||||
@click="startCellEdit(groupName, index, key, field)">
|
||||
|
||||
<template v-if="isCellEditing(groupName, index, key)">
|
||||
<input
|
||||
v-if="field.type === 'input'"
|
||||
:type="field.inputType || 'text'"
|
||||
v-model="position[key]"
|
||||
@blur="saveCellEdit(position, key, groupName, index)"
|
||||
@keyup.enter="saveCellEdit(position, key, groupName, index)"
|
||||
class="form-control form-control-sm"
|
||||
autofocus
|
||||
/>
|
||||
<textarea
|
||||
v-else-if="field.type === 'textarea'"
|
||||
v-model="position[key]"
|
||||
@blur="saveCellEdit(position, key, groupName, index)"
|
||||
@keyup.enter.prevent="saveCellEdit(position, key, groupName, index)"
|
||||
class="form-control form-control-sm"
|
||||
rows="1"
|
||||
autofocus
|
||||
></textarea>
|
||||
</template>
|
||||
<template v-else>
|
||||
<tt-resolver
|
||||
:key="index + key + position[key]"
|
||||
v-if="position[key] && (field.customFieldReference || field.type === 'autocomplete')"
|
||||
:autocomplete="!field.customFieldReference && field.type === 'autocomplete'"
|
||||
:reference="field.customFieldReference || field.apiUrl"
|
||||
:value="position[key]"
|
||||
/>
|
||||
<span v-else-if="field.type === 'checkbox'">{{ position[key] ? 'Ja' : 'Nein' }}</span>
|
||||
<span v-else>{{ formatFieldValue(position[key] ?? position[key + '_text'], field) }}</span>
|
||||
</template>
|
||||
</td>
|
||||
<td class="d-flex justify-content-end">
|
||||
<select v-if="groupMode" v-model="position._group" @change="$set(position, '_group', $event.target.value)">
|
||||
<option v-for="group in allGroups" :value="group">{{ group }}</option>
|
||||
</select>
|
||||
<tt-button @click="editEntry(getActualIndex(position, groupName, index))" sm additional-class="btn-primary" icon="fa fa-edit"/>
|
||||
<tt-button @click="deleteEntry(getActualIndex(position, groupName, index))" sm additional-class="btn-danger" icon="fa fa-trash"/>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`,
|
||||
methods: {
|
||||
updateField(key, value) {
|
||||
this.$set(this.formData, key, value);
|
||||
},
|
||||
defaultValidateForm(formData) {
|
||||
for (const field of this.config["validateFormOptions"]) {
|
||||
if (!(formData[field.key] || formData[field.key + '_text'])) {
|
||||
window.notify('error', field.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
checkEmitDisplayValueAutocomplete() {
|
||||
for (const [key, field] of Object.entries(this.config.fields)) {
|
||||
if ((typeof field.showCondition === 'function' && field.showCondition(this.formData) === true || !field.showCondition) && field.type === 'autocomplete' && field.emitDisplayValue && (isNaN(this.formData[key]) || !this.formData[key]) && this.$refs['autocomplete-' + key][0]) {
|
||||
this.$set(this.formData, key + '_text', this.$refs['autocomplete-' + key][0].displayValue);
|
||||
this.$delete(this.formData, key);
|
||||
}
|
||||
|
||||
if ((typeof field.showCondition === 'function' && field.showCondition(this.formData) === true || !field.showCondition) && field.type === 'input-article' && field.emitDisplayValue && (isNaN(this.formData[key]) || !this.formData[key]) && this.$refs['article-' + key][0]) {
|
||||
console.log(this.$refs['article-' + key][0].$refs.autocomplete);
|
||||
this.$set(this.formData, key + '_text', this.$refs['article-' + key][0].$refs.autocomplete.displayValue);
|
||||
this.$delete(this.formData, key);
|
||||
}
|
||||
|
||||
if (field.emitDisplayValue && this.formData[key] !== null && this.formData[key] !== undefined) {
|
||||
this.$delete(this.formData, key + '_text');
|
||||
}
|
||||
|
||||
}
|
||||
},
|
||||
async saveEntry() {
|
||||
this.checkEmitDisplayValueAutocomplete();
|
||||
if (this.config.hasOwnProperty('validateFormOptions') && !this.defaultValidateForm(this.formData)) return;
|
||||
else if (this.config.validateForm && !await this.config.validateForm(this.formData)) return;
|
||||
|
||||
if (this.selectedIndex === null) this.positions.push(this.formData);
|
||||
else this.$set(this.positions, this.selectedIndex, this.formData);
|
||||
|
||||
if (this.config.customOrdering) {
|
||||
this.positions.sort((a, b) => a[this.config.customOrdering] - b[this.config.customOrdering]);
|
||||
}
|
||||
|
||||
this.$emit('input', this.positions);
|
||||
this.resetForm();
|
||||
},
|
||||
addGroup() {
|
||||
this.positions.push({_group: this.groupName});
|
||||
this.groupName = '';
|
||||
},
|
||||
startGroupEdit(groupName) {
|
||||
this.editingGroupName = groupName;
|
||||
this.tempGroupName = groupName;
|
||||
},
|
||||
saveGroupName(oldGroupName) {
|
||||
if (this.tempGroupName.trim() === '') {
|
||||
window.notify('error', 'Gruppenname darf nicht leer sein');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.tempGroupName !== oldGroupName) {
|
||||
// Update all positions with the old group name to the new group name
|
||||
this.positions.forEach(position => {
|
||||
if (position._group === oldGroupName) {
|
||||
this.$set(position, '_group', this.tempGroupName);
|
||||
}
|
||||
});
|
||||
this.$emit('input', this.positions);
|
||||
}
|
||||
|
||||
this.editingGroupName = null;
|
||||
this.tempGroupName = '';
|
||||
},
|
||||
cancelGroupEdit() {
|
||||
this.editingGroupName = null;
|
||||
this.tempGroupName = '';
|
||||
},
|
||||
deleteGroup(groupName) {
|
||||
if (confirm(`Möchten Sie die Gruppe "${groupName}" wirklich löschen? Alle Einträge werden in "Keine Gruppe" verschoben.`)) {
|
||||
// Move all items from this group to "Keine Gruppe" and remove empty group entries
|
||||
this.positions = this.positions.filter(position => {
|
||||
if (position._group === groupName) {
|
||||
// If it's just a group header (only has _group property), remove it
|
||||
if (Object.keys(position).length === 1) {
|
||||
return false;
|
||||
}
|
||||
// Otherwise, move to "Keine Gruppe"
|
||||
this.$set(position, '_group', 'Keine Gruppe');
|
||||
}
|
||||
return true;
|
||||
});
|
||||
this.$emit('input', this.positions);
|
||||
}
|
||||
},
|
||||
getActualIndex(position, groupName, groupIndex) {
|
||||
// Find the actual index in the positions array
|
||||
if (!this.groupMode) return groupIndex;
|
||||
|
||||
let actualIndex = 0;
|
||||
let currentGroupIndex = 0;
|
||||
|
||||
for (let i = 0; i < this.positions.length; i++) {
|
||||
const pos = this.positions[i];
|
||||
const posGroup = pos._group ?? 'Keine Gruppe';
|
||||
|
||||
if (posGroup === groupName && Object.keys(pos).length > 1) {
|
||||
if (currentGroupIndex === groupIndex) {
|
||||
return i;
|
||||
}
|
||||
currentGroupIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
return groupIndex; // Fallback
|
||||
},
|
||||
async editEntry(index) {
|
||||
this.selectedIndex = index;
|
||||
for (const [key, field] of Object.entries(this.config.fields)) {
|
||||
if (field.type === 'autocomplete' && field.emitDisplayValue) {
|
||||
await this.$nextTick();
|
||||
if (this.positions[index][key + '_text'] && this.$refs['autocomplete-' + key][0]) {
|
||||
console.log('inhere');
|
||||
this.$refs['autocomplete-' + key][0].displayValue = this.positions[index][key + '_text'];
|
||||
this.$set(this.formData, key, this.positions[index][key]);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.formData = {...this.positions[index]};
|
||||
|
||||
// Scroll to the form container
|
||||
this.$nextTick(() => {
|
||||
this.$refs.formContainer.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
});
|
||||
},
|
||||
deleteEntry(index) {
|
||||
this.positions.splice(index, 1);
|
||||
this.$emit('input', this.positions);
|
||||
},
|
||||
resetForm() {
|
||||
this.formData = {};
|
||||
for (const [key, field] of Object.entries(this.config.fields)) {
|
||||
if (
|
||||
typeof field.showCondition === 'function' && field.showCondition(this.formData) === true &&
|
||||
field.type === 'autocomplete' && field.emitDisplayValue && this.$refs['autocomplete-' + key][0]) {
|
||||
this.$refs['autocomplete-' + key][0].displayValue = '';
|
||||
}
|
||||
if (field.inputType === 'date') {
|
||||
this.$set(this.formData, key, moment().format('YYYY-MM-DD'));
|
||||
}
|
||||
}
|
||||
this.selectedIndex = null;
|
||||
},
|
||||
formatFieldValue(value, field) {
|
||||
if (field.formatter) return field.formatter(value);
|
||||
if (field.inputType === 'date') return moment(value).format('DD.MM.YYYY');
|
||||
return value;
|
||||
},
|
||||
// New methods for instant editing
|
||||
startCellEdit(groupName, index, key, field) {
|
||||
if (!field.editableInTable || !(field.type === 'input' || field.type === 'textarea')) return;
|
||||
this.editingCell = { groupName, index, key };
|
||||
this.$nextTick(() => {
|
||||
const inputElement = this.$el.querySelector(`.editable-cell input[type="${field.type}"], .editable-cell textarea`);
|
||||
if (inputElement) {
|
||||
inputElement.focus();
|
||||
}
|
||||
});
|
||||
},
|
||||
saveCellEdit(position, key, groupName, index) {
|
||||
this.editingCell = { groupName: null, index: null, key: null };
|
||||
// Ensure data is updated in the original positions array
|
||||
const actualIndex = this.getActualIndex(position, groupName, index);
|
||||
this.$set(this.positions, actualIndex, position);
|
||||
this.$emit('input', this.positions);
|
||||
},
|
||||
isCellEditing(groupName, index, key) {
|
||||
return this.editingCell.groupName === groupName &&
|
||||
this.editingCell.index === index &&
|
||||
this.editingCell.key === key;
|
||||
}
|
||||
},
|
||||
//TODO: cleanup
|
||||
created() {
|
||||
if (this.config["customMethods"]) Object.assign(this, this.config.customMethods);
|
||||
if (!this.positions) this.positions = [];
|
||||
if (typeof this.positions === 'string') {
|
||||
try {
|
||||
this.positions = JSON.parse(this.positions);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
this.positions = [];
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.resetForm();
|
||||
},
|
||||
computed: {
|
||||
groupedPositions() {
|
||||
const groups = {};
|
||||
for (const position of this.positions) {
|
||||
const group = position._group ?? 'Keine Gruppe';
|
||||
if (!groups[group]) groups[group] = [];
|
||||
if (Object.keys(position).length !== 1) groups[group].push(position);
|
||||
}
|
||||
return groups;
|
||||
},
|
||||
positionsToRender() {
|
||||
return this.groupMode ? this.groupedPositions : {'': this.positions};
|
||||
},
|
||||
allGroups() {
|
||||
return Object.keys(this.groupedPositions);
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
value: {
|
||||
handler() {
|
||||
this.positions = this.value;
|
||||
},
|
||||
deep: true
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user