Merge branch 'Warehouse/improve' into 'master'

improved new warehouse stuff

See merge request fronk/thetool!1539
This commit is contained in:
Luca Haid
2025-07-14 08:28:15 +00:00
6 changed files with 698 additions and 504 deletions

View File

@@ -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>

View File

@@ -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;

View File

@@ -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);
}
// 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' => 'Log entry created']);
}
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()]);
}
}

View File

@@ -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'},

View File

@@ -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) {
@@ -202,21 +227,49 @@ Vue.component('change-status-modal', {
</ul>
</div>
<tt-textarea label="Bemerkung" v-model="note" sm/>
<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>
<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 === 'accepted'">
@@ -247,10 +300,11 @@ Vue.component('change-status-modal', {
</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},
@@ -552,8 +606,6 @@ Vue.component('warehouse-order-detail', {
<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-->
<template v-if="log.fileIds">
<div v-for="file in JSON.parse(log.fileIds)">
<tt-file :id="file"/>
@@ -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,

View File

@@ -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">
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>
</div>
<span v-else>{{ text }}</span>
`,
<span v-else>{{ text }}</span>`,
async created() {
const cacheKey = `${this.reference}_${this.value}_${this.autocomplete}`;
const cached = JSON.parse(localStorage.getItem(cacheKey) || 'null');
@@ -40,9 +35,7 @@ Vue.component('tt-resolver', {
}
});
Vue.component('tt-positions-manager',
{
Vue.component('tt-positions-manager', {
props: {
value: {type: [Array, String], required: false},
config: {type: Object, required: true},
@@ -58,6 +51,12 @@ Vue.component('tt-positions-manager',
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: `
@@ -65,7 +64,7 @@ Vue.component('tt-positions-manager',
<template v-if="config['header']">
<h4 class="text-center">{{ config["header"] }}</h4>
</template>
<div class="form-container">
<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]">
@@ -175,7 +174,31 @@ Vue.component('tt-positions-manager',
</td>
</tr>
<tr v-for="(position, index) in group" :key="groupMode ? groupName + index : index">
<td v-for="(field, key) in config.fields">
<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')"
@@ -185,6 +208,7 @@ Vue.component('tt-positions-manager',
/>
<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)">
@@ -330,6 +354,11 @@ Vue.component('tt-positions-manager',
}
}
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);
@@ -354,6 +383,29 @@ Vue.component('tt-positions-manager',
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() {
@@ -396,4 +448,4 @@ Vue.component('tt-positions-manager',
deep: true
}
}
})
});