diff --git a/application/WarehouseArticle/WarehouseArticleController.php b/application/WarehouseArticle/WarehouseArticleController.php index fd3af534c..cfee60901 100644 --- a/application/WarehouseArticle/WarehouseArticleController.php +++ b/application/WarehouseArticle/WarehouseArticleController.php @@ -56,12 +56,16 @@ class WarehouseArticleController extends TTCrud { protected function beforeCreate() { if (!in_array($this->user->id, [2, 5, 6, 145, 14])) self::sendError("Sie haben keine Berechtigung, Artikel zu erstellen."); + + $this->validateArticleNumber($_POST); return true; } protected function beforeUpdate($postData): bool { if (!in_array($this->user->id, [2, 5, 6, 145, 14])) self::sendError("Sie haben keine Berechtigung, Artikel zu bearbeiten."); + + $this->validateArticleNumber($postData, $postData['id'] ?? null); (new WarehouseHistoryController)->create($postData, $this->mod); return true; } @@ -84,6 +88,38 @@ class WarehouseArticleController extends TTCrud { self::updateSellPrices($postData['id']); } + /** + * Validate article number for duplicates and correct category prefix + */ + private function validateArticleNumber(array $postData, ?int $excludeId = null): void { + $articleNumber = $postData['articleNumber'] ?? ''; + $categoryId = $postData['category_id'] ?? null; + + if (empty($articleNumber)) { + self::sendError("Artikelnummer ist erforderlich."); + } + + // Check for duplicate article number + $existingArticles = WarehouseArticleModel::getAll(['articleNumber' => $articleNumber]); + foreach ($existingArticles as $existing) { + if ($excludeId === null || $existing->id != $excludeId) { + self::sendError("Artikelnummer '{$articleNumber}' existiert bereits (Artikel ID: {$existing->id})."); + } + } + + // Validate category prefix + if ($categoryId) { + $category = WarehouseCategory::get($categoryId); + if ($category && $category->articleNumberPrefix) { + $expectedPrefix = $category->articleNumberPrefix; + $articlePrefix = substr($articleNumber, 0, strlen($expectedPrefix)); + if ($articlePrefix !== $expectedPrefix) { + self::sendError("Artikelnummer muss mit dem Kategorie-Prefix '{$expectedPrefix}' beginnen."); + } + } + } + } + public static function updateSellPrices(int $id): void { // Added return type hint $a = WarehouseArticleModel::get($id); if (!$a instanceof WarehouseArticleModel) throw new Exception("Invalid article type"); diff --git a/public/js/pages/WarehouseArticle/WarehouseArticle.css b/public/js/pages/WarehouseArticle/WarehouseArticle.css index e61cde973..706bbb0ff 100644 --- a/public/js/pages/WarehouseArticle/WarehouseArticle.css +++ b/public/js/pages/WarehouseArticle/WarehouseArticle.css @@ -13,6 +13,24 @@ background-color: #f8d7da !important; } +/* Last Edited Row Highlighting */ +.last-edited-row { + background-color: #fff3cd !important; + animation: highlight-fade 5s ease-out forwards; +} + +@keyframes highlight-fade { + 0% { + background-color: #fff3cd; + } + 70% { + background-color: #fff3cd; + } + 100% { + background-color: transparent; + } +} + /* * Modal Layout */ diff --git a/public/js/pages/WarehouseArticle/WarehouseArticle.js b/public/js/pages/WarehouseArticle/WarehouseArticle.js index 38572c325..362a3943b 100644 --- a/public/js/pages/WarehouseArticle/WarehouseArticle.js +++ b/public/js/pages/WarehouseArticle/WarehouseArticle.js @@ -1,5 +1,13 @@ +// Track last edited article for highlighting +window.TT_CONFIG.lastEditedArticleId = null; + window.TT_CONFIG.CRUD_CONFIG.customRowClass = (row) => { - if (row.isEndOfLife) return 'end-of-life'; + const classes = []; + if (row.isEndOfLife) classes.push('end-of-life'); + if (window.TT_CONFIG.lastEditedArticleId && row.id == window.TT_CONFIG.lastEditedArticleId) { + classes.push('last-edited-row'); + } + return classes.join(' '); } async function handleApiResponse(responsePromise) { @@ -14,15 +22,19 @@ async function handleApiResponse(responsePromise) { } Vue.component('warehouse-article-prices', { - props: {id: {type: Number, required: true}}, + props: { + id: {type: Number, required: true}, + cheapestPurchasePrice: {type: Number, default: null} + }, template: `
Artikelpreise überschreiben
- - + - +
@@ -463,6 +530,9 @@ Vue.component('warehouse-article-modal', { data: () => ({ loading: false, saving: false, + originalCategoryId: null, + cheapestPurchasePrice: null, + originalFormData: null, formData: { title: '', description: '', @@ -548,6 +618,12 @@ Vue.component('warehouse-article-modal', { isSbidiShop: !!data.isSbidiShop, isSbidiShopHide: !!data.isSbidiShopHide }; + // Store original category to detect changes + this.originalCategoryId = data.category_id; + // Store cheapest purchase price for price calculations + this.cheapestPurchasePrice = data.cheapestPurchasePrice || null; + // Store original form data to detect changes + this.originalFormData = JSON.stringify(this.formData); } } catch (e) { window.notify('error', 'Fehler beim Laden'); @@ -574,7 +650,10 @@ Vue.component('warehouse-article-modal', { }; }, async onCategoryChange(categoryId) { - if (!categoryId || this.isEditMode) return; + if (!categoryId) return; + // In edit mode, only regenerate if category actually changed from original + if (this.isEditMode && categoryId == this.originalCategoryId) return; + try { const res = await axios.get(`${window.TT_CONFIG.BASE_PATH}/WarehouseArticle/getNextArticleNumber`, { params: { categoryId: categoryId } @@ -586,29 +665,77 @@ Vue.component('warehouse-article-modal', { console.error('Failed to get next article number:', e); } }, - async save() { + async save(closeAfterSave = true) { if (!this.isValid) return; this.saving = true; try { - const endpoint = this.isEditMode ? 'update' : 'create'; - const payload = { - ...this.formData, - isSerialDocumentation: this.formData.isSerialDocumentation ? 1 : 0, - isEndOfLife: this.formData.isEndOfLife ? 1 : 0, - isEShop: this.formData.isEShop ? 1 : 0, - isEShopHide: this.formData.isEShopHide ? 1 : 0, - isSbidiShop: this.formData.isSbidiShop ? 1 : 0, - isSbidiShopHide: this.formData.isSbidiShopHide ? 1 : 0 - }; - if (this.isEditMode) payload.id = Number(this.id); + let savedPrices = false; + let savedDistributors = false; + let savedArticle = false; - const res = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WarehouseArticle/${endpoint}`, payload); - if (res.data.success) { - window.notify('success', res.data.message || 'Gespeichert'); - if (!this.isEditMode && window.TT_CONFIG.CRUD_CONFIG.reopenOnCreate) this.$emit('reopen', res.data.id); - else this.$emit('close'); + // Save prices and distributors first (only in edit mode) + if (this.isEditMode) { + if (this.$refs.pricesComponent && this.$refs.pricesComponent.hasPendingChanges()) { + await this.$refs.pricesComponent.savePrices(); + savedPrices = true; + } + if (this.$refs.distributorComponent && this.$refs.distributorComponent.hasPendingChanges()) { + await this.$refs.distributorComponent.saveDistributors(); + savedDistributors = true; + } + } + + // Check if main article data actually changed + const currentFormData = JSON.stringify(this.formData); + const articleDataChanged = !this.isEditMode || this.originalFormData !== currentFormData; + + if (articleDataChanged) { + const endpoint = this.isEditMode ? 'update' : 'create'; + const payload = { + ...this.formData, + isSerialDocumentation: this.formData.isSerialDocumentation ? 1 : 0, + isEndOfLife: this.formData.isEndOfLife ? 1 : 0, + isEShop: this.formData.isEShop ? 1 : 0, + isEShopHide: this.formData.isEShopHide ? 1 : 0, + isSbidiShop: this.formData.isSbidiShop ? 1 : 0, + isSbidiShopHide: this.formData.isSbidiShopHide ? 1 : 0 + }; + if (this.isEditMode) payload.id = Number(this.id); + + const res = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WarehouseArticle/${endpoint}`, payload); + if (res.data.success) { + savedArticle = true; + // Track last edited article for row highlighting + window.TT_CONFIG.lastEditedArticleId = this.isEditMode ? Number(this.id) : res.data.id; + + if (!this.isEditMode) { + // For new articles, reopen in edit mode + window.notify('success', res.data.message || 'Gespeichert'); + this.$emit('reopen', res.data.id); + return; + } + } else { + window.notify('error', res.data.message || 'Fehler beim Speichern'); + return; + } + } + + // Show success message if anything was saved + if (savedArticle || savedPrices || savedDistributors) { + window.TT_CONFIG.lastEditedArticleId = Number(this.id); + window.notify('success', 'Gespeichert'); } else { - window.notify('error', res.data.message || 'Fehler beim Speichern'); + window.notify('info', 'Keine Änderungen'); + } + + if (closeAfterSave) { + // Close modal if requested + this.$emit('close'); + } else { + // Stay open - reload data to refresh prices/distributors + await this.loadArticle(); + if (this.$refs.pricesComponent) await this.$refs.pricesComponent.fetchArticlePrices(); + if (this.$refs.distributorComponent) await this.$refs.distributorComponent.fetchArticleDistributors(); } } catch (e) { window.notify('error', 'Fehler beim Speichern');