Files
thetool/public/js/pages/WarehouseArticle/WarehouseArticle.js

694 lines
29 KiB
JavaScript

window.TT_CONFIG.CRUD_CONFIG.customRowClass = (row) => {
if (row.isEndOfLife) return 'end-of-life';
}
async function handleApiResponse(responsePromise) {
const res = await responsePromise;
if (!res.data.success) {
const errors = res.data.errors;
const errorMessage = Array.isArray(errors) ? errors.join(', ') : Object.values(errors).join(', ');
return window.notify('error', `Fehler: ${errorMessage}`);
}
window.notify('success', res.data.message || 'Erfolgreich');
window.dispatchEvent(new Event('refreshTable'));
}
Vue.component('warehouse-article-prices', {
props: {id: {type: Number, required: true}},
template: `
<div class="wa-prices-section">
<h5 class="wa-section-title"><i class="fas fa-tags mr-2"></i>Artikelpreise überschreiben</h5>
<div class="wa-prices-grid-dense">
<div v-for="(price, typeTitle) in articlePrices" :key="typeTitle" class="wa-price-item">
<div class="wa-price-header">
<i v-if="price.isRobot" class="fas fa-robot mr-1 text-muted" style="font-size: 0.75rem;"></i>
<strong style="font-size: 0.8rem;">{{ typeTitle }}</strong>
<i v-if="price.pendingChanges" class="fas fa-exclamation-triangle text-warning ml-auto"
style="font-size: 0.75rem;" title="Nicht gespeichert"></i>
</div>
<div class="wa-price-body">
<tt-input
v-model="price.priceMultiplier"
@input="handleFactorInput(price)"
placeholder="Faktor"
type="number"
sm no-form-group/>
<tt-input
v-model="price.priceOverride"
@input="handlePriceInput(price)"
placeholder="Preis €"
type="number"
sm no-form-group/>
<div class="wa-price-actions">
<button class="btn btn-sm btn-primary" @click="savePrice(price)" title="Speichern">
<i class="fas fa-save"></i>
</button>
<button class="btn btn-sm btn-danger" @click="deletePrice(price)" :disabled="price.isRobot" title="Löschen">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
</div>
</div>
</div>
`,
data: () => ({window, articlePrices: {}}),
async mounted() {
await this.fetchArticlePrices();
},
methods: {
handleFactorInput(price) {
if (price.priceMultiplier) price.priceOverride = null;
price.pendingChanges = true;
},
handlePriceInput(price) {
if (price.priceOverride) price.priceMultiplier = null;
price.pendingChanges = true;
},
async fetchArticlePrices() {
const [pricesRes, typesRes] = await Promise.all([
axios.post(`${window['TT_CONFIG']['BASE_PATH']}/WarehouseArticlePrice/get`, {filters: {articleId: this.id}}),
axios.post(`${window['TT_CONFIG']['BASE_PATH']}/WarehouseArticlePriceType/get`)
]);
const prices = {};
typesRes.data.rows.forEach(type => prices[type.title] = {
isRobot: true,
articlePriceTypeId: type.id,
priceMultiplier: type.defaultPriceFactor,
priceOverride: null
});
pricesRes.data.rows.forEach(pData => {
const type = typesRes.data.rows.find(t => t.id === pData.articlePriceTypeId);
if (!type) return;
prices[type.title] = {
id: pData.id,
isRobot: false,
pendingChanges: false,
articlePriceTypeId: pData.articlePriceTypeId,
priceMultiplier: pData.priceMultiplier,
priceOverride: pData.priceOverride
};
});
this.articlePrices = prices;
},
async savePrice(price) {
const payload = {
articleId: this.id,
articlePriceTypeId: price.articlePriceTypeId,
priceMultiplier: price.priceMultiplier ? parseFloat(price.priceMultiplier.toString().replace(',', '.')) : null,
priceOverride: price.priceOverride ? parseFloat(price.priceOverride.toString().replace(',', '.')) : null
};
const endpoint = price.isRobot ? 'create' : 'update';
const data = price.isRobot ? payload : {id: price.id, ...payload};
await this.window.handleApiResponse(axios.post(`${window['TT_CONFIG']['BASE_PATH']}/WarehouseArticlePrice/${endpoint}`, data));
await this.fetchArticlePrices();
},
async deletePrice(price) {
const payload = {id: price.id, articleId: this.id, articlePriceTypeId: price.articlePriceTypeId}
await this.window.handleApiResponse(axios.post(`${window['TT_CONFIG']['BASE_PATH']}/WarehouseArticlePrice/delete`, payload));
await this.fetchArticlePrices();
}
}
});
Vue.component('warehouse-distributor-directory-modal', {
props: {
show: { type: Boolean, default: false },
allDistributors: { type: Array, default: () => [] },
articleDistributors: { type: Array, default: () => [] }
},
data: () => ({
distributorSearch: ''
}),
watch: {
show(newVal) {
if (newVal) document.documentElement.style.overflow = 'hidden';
}
},
computed: {
filteredDistributors() {
if (!this.distributorSearch) return this.allDistributors;
const search = this.distributorSearch.toLowerCase();
return this.allDistributors.filter(d => d.name.toLowerCase().includes(search));
},
alphabetWithDistributors() {
const letters = new Set();
this.filteredDistributors.forEach(d => {
const firstChar = d.name.charAt(0).toUpperCase();
if (/[A-Z]/.test(firstChar)) letters.add(firstChar);
});
return Array.from(letters).sort();
}
},
methods: {
getDistributorsByLetter(letter) {
return this.filteredDistributors.filter(d => d.name.charAt(0).toUpperCase() === letter);
},
isDistributorAdded(distributorId) {
return this.articleDistributors.some(d => d.distributorId === distributorId);
},
selectDistributor(distributorId) {
this.$emit('select', distributorId);
this.$emit('close');
},
close() {
this.$emit('close');
}
},
template: `
<div v-if="show" class="modal fade show d-block" tabindex="-1" style="background: rgba(0,0,0,0.5);" @click.self="close">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="fas fa-truck mr-2"></i>
Lieferant hinzufügen
</h5>
<button type="button" class="close" @click="close">
<span>&times;</span>
</button>
</div>
<div class="modal-body">
<div class="wa-directory-header mb-3">
<tt-input
v-model="distributorSearch"
placeholder="Lieferant suchen..."
sm
prefix-icon="fas fa-search"/>
</div>
<div class="wa-directory-list">
<div v-for="letter in alphabetWithDistributors" :key="letter" class="wa-directory-group">
<div class="wa-directory-letter">{{ letter }}</div>
<div class="wa-directory-items">
<button v-for="dist in getDistributorsByLetter(letter)"
:key="dist.id"
class="wa-directory-item"
:class="{ 'active': isDistributorAdded(dist.id) }"
@click="selectDistributor(dist.id)"
:disabled="isDistributorAdded(dist.id)">
{{ dist.name }}
<i v-if="isDistributorAdded(dist.id)" class="fas fa-check ml-1"></i>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
`
});
Vue.component('warehouse-article-distributor', {
props: {id: {type: Number, required: true}},
template: `
<div class="wa-distributors-section">
<div class="wa-section-title-with-action">
<h5 class="wa-section-title mb-0"><i class="fas fa-truck mr-2"></i>Lieferanten</h5>
<button class="btn btn-sm btn-primary" @click="showDirectoryModal = true">
<i class="fas fa-plus mr-1"></i>Lieferant hinzufügen
</button>
</div>
<warehouse-distributor-directory-modal
:show="showDirectoryModal"
:all-distributors="allDistributors"
:article-distributors="articleDistributors"
@select="addDistributor"
@close="showDirectoryModal = false"/>
<div v-if="articleDistributors.length === 0" class="text-muted text-center py-3" style="font-size: 0.85rem;">
<i class="fas fa-info-circle mr-1"></i>Keine Lieferanten zugewiesen
</div>
<div v-else class="wa-distributors-grid">
<div v-for="(distributor, index) in articleDistributors" :key="distributor.id || ('new-' + index)" class="wa-distributor-row">
<div class="wa-distributor-name">
<strong>{{ getDistributorName(distributor.distributorId) }}</strong>
<i v-if="distributor.pendingChanges || !distributor.id"
class="fas fa-exclamation-triangle text-warning ml-1"
style="font-size: 0.75rem;"
title="Nicht gespeichert"></i>
</div>
<div class="wa-distributor-inputs">
<tt-input
v-model="distributor.externalArticleNumber"
@input="distributor.pendingChanges = true"
placeholder="Externe Art.-Nr."
sm no-form-group/>
<tt-input
v-model="distributor.purchasePrice"
@input="distributor.pendingChanges = true"
placeholder="Einkaufspreis €"
sm no-form-group/>
</div>
<div class="wa-distributor-actions">
<button class="btn btn-sm btn-primary" @click="saveDistributor(distributor)" title="Speichern">
<i class="fas fa-save"></i>
</button>
<button class="btn btn-sm btn-danger" @click="deleteDistributor(distributor.id)" :disabled="!distributor.id" title="Löschen">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
</div>
</div>
`,
data: () => ({
window,
articleDistributors: [],
allDistributors: [],
showDirectoryModal: false
}),
async mounted() {
await Promise.all([
this.fetchArticleDistributors(),
this.fetchAllDistributors()
]);
},
methods: {
async fetchAllDistributors() {
const res = await axios.post(`${window['TT_CONFIG']['BASE_PATH']}/WarehouseDistributor/get`, {
pagination: { per_page: 10000 },
order: { key: 'name', order: 'ASC' }
});
this.allDistributors = res.data.rows || [];
},
async fetchArticleDistributors() {
const res = await axios.post(`${window['TT_CONFIG']['BASE_PATH']}/WarehouseArticleDistributor/get`, {filters: {articleId: this.id}});
this.articleDistributors = res.data.rows;
},
getDistributorName(id) {
const dist = this.allDistributors.find(d => d.id === id);
return dist ? dist.name : 'Unbekannt';
},
addDistributor(distributorId) {
if (this.articleDistributors.some(d => d.distributorId === distributorId)) return;
this.articleDistributors.push({
articleId: this.id,
distributorId: distributorId,
externalArticleNumber: null,
purchasePrice: null,
pendingChanges: true
});
},
async saveDistributor(distributor) {
delete distributor.pendingChanges;
distributor.purchasePrice = distributor.purchasePrice ? parseFloat(distributor.purchasePrice.toString().replace(',', '.')) : null;
await this.window.handleApiResponse(axios.post(`${window['TT_CONFIG']['BASE_PATH']}/WarehouseArticleDistributor/${distributor.id ? 'update' : 'create'}`, distributor));
await this.fetchArticleDistributors();
},
async deleteDistributor(distributorId) {
if (!confirm('Lieferant wirklich entfernen?')) return;
await this.window.handleApiResponse(axios.post(`${window['TT_CONFIG']['BASE_PATH']}/WarehouseArticleDistributor/delete`, {id: distributorId, articleId: this.id}));
await this.fetchArticleDistributors();
}
}
});
Vue.component('warehouse-article-modal', {
props: {
id: { type: [Number, String], required: true }
},
template: `
<div class="modal fade show d-block" tabindex="-1" style="background: rgba(0,0,0,0.5);" @click.self="close">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="fas fa-box mr-2"></i>
{{ isEditMode ? 'Artikel bearbeiten' : 'Artikel erstellen' }}
</h5>
<button type="button" class="close" @click="close">
<span>&times;</span>
</button>
</div>
<div class="modal-body">
<div v-if="loading" class="text-center py-5">
<i class="fas fa-spinner fa-spin fa-2x text-muted"></i>
</div>
<div v-else class="wa-modal-content">
<!-- Basic Information -->
<div class="wa-section">
<div class="row">
<div class="col-md-12" :class="{ 'wa-field-disabled': !isEditMode && !formData.category_id }">
<tt-input
label="Titel"
v-model="formData.title"
placeholder="Artikel Titel"
required
sm/>
</div>
<div class="col-md-12" :class="{ 'wa-field-disabled': !isEditMode && !formData.category_id }">
<tt-textarea
label="Beschreibung"
v-model="formData.description"
required
rows="3"/>
</div>
<div class="col-md-4">
<tt-select
label="Kategorie"
v-model="formData.category_id"
:options="categoryOptions"
@input="onCategoryChange"
required
sm/>
</div>
<div class="col-md-4">
<tt-input
label="Artikel-Nummer"
v-model="formData.articleNumber"
placeholder="Wird automatisch generiert"
required
disabled
form-label
sm/>
</div>
<div class="col-md-2" :class="{ 'wa-field-disabled': !isEditMode && !formData.category_id }">
<tt-select
label="Einheit"
v-model="formData.unit"
:options="unitOptions"
required
sm/>
</div>
<div class="col-md-2" :class="{ 'wa-field-disabled': !isEditMode && !formData.category_id }">
<tt-select
label="Erlöskonto"
v-model="formData.revenueAccount"
:options="revenueAccountOptions"
required
sm/>
</div>
</div>
</div>
<!-- Prices Section -->
<warehouse-article-prices v-if="isEditMode" :id="Number(id)"/>
<!-- Distributors Section -->
<warehouse-article-distributor v-if="isEditMode" :id="Number(id)"/>
<!-- Additional Attributes -->
<div class="wa-section" :class="{ 'wa-field-disabled': !isEditMode && !formData.category_id }">
<h5 class="wa-section-title"><i class="fas fa-cog mr-2"></i>Zusätzliche Artikel Attribute</h5>
<div class="row">
<div class="col-md-6">
<tt-input
label="Warnmenge"
type="number"
v-model.number="formData.warningAmount"
placeholder="0"
required
sm/>
</div>
<div class="col-md-6">
<tt-input
label="Kritische Menge"
type="number"
v-model.number="formData.criticalAmount"
placeholder="0"
required
sm/>
</div>
</div>
<div class="wa-checkbox-grid">
<label class="wa-checkbox-item">
<input type="checkbox" v-model="formData.isSerialDocumentation">
<span class="wa-checkmark"></span>
<span class="wa-checkbox-label">Seriennummern</span>
</label>
<label class="wa-checkbox-item">
<input type="checkbox" v-model="formData.isEndOfLife">
<span class="wa-checkmark"></span>
<span class="wa-checkbox-label">End of Life</span>
</label>
<label class="wa-checkbox-item">
<input type="checkbox" v-model="formData.isEShop">
<span class="wa-checkmark"></span>
<span class="wa-checkbox-label">Ist E-Shop</span>
</label>
<label class="wa-checkbox-item">
<input type="checkbox" v-model="formData.isEShopHide">
<span class="wa-checkmark"></span>
<span class="wa-checkbox-label">E-Shop Versteckt</span>
</label>
<label class="wa-checkbox-item">
<input type="checkbox" v-model="formData.isSbidiShop">
<span class="wa-checkmark"></span>
<span class="wa-checkbox-label">Ist SBIDI-Shop</span>
</label>
<label class="wa-checkbox-item">
<input type="checkbox" v-model="formData.isSbidiShopHide">
<span class="wa-checkmark"></span>
<span class="wa-checkbox-label">SBIDI-Shop Versteckt</span>
</label>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="close">Abbrechen</button>
<button type="button" class="btn btn-primary" @click="save" :disabled="saving || !isValid">
<i v-if="saving" class="fas fa-spinner fa-spin mr-1"></i>
<i v-else class="fas fa-save mr-1"></i>
Speichern
</button>
</div>
</div>
</div>
</div>
`,
data: () => ({
loading: false,
saving: false,
formData: {
title: '',
description: '',
category_id: null,
articleNumber: '',
unit: 'Stk.',
revenueAccount: 0,
warningAmount: 0,
criticalAmount: 0,
isSerialDocumentation: false,
isEndOfLife: false,
isEShop: false,
isEShopHide: false,
isSbidiShop: false,
isSbidiShopHide: false
}
}),
computed: {
isEditMode() {
return this.id !== 'create';
},
categoryOptions() {
const catCol = window.TT_CONFIG.CRUD_CONFIG.columns.find(c => c.key === 'category_id');
return catCol ? [{ value: null, text: '-- Bitte wählen --' }, ...catCol.modal.items] : [];
},
unitOptions() {
return [
{ value: 'Stk.', text: 'Stk.' },
{ value: 'Pau.', text: 'Pau.' },
{ value: 'm.', text: 'm.' },
{ value: 'Std.', text: 'Std.' },
{ value: 'km', text: 'km' }
];
},
revenueAccountOptions() {
return [
{ value: 0, text: 'Dienstleistungen' },
{ value: 1, text: 'Handelswaren' }
];
},
isValid() {
return this.formData.title &&
this.formData.description &&
this.formData.category_id &&
this.formData.articleNumber &&
this.formData.unit;
}
},
mounted() {
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
document.documentElement.style.overflow = 'hidden';
document.documentElement.style.paddingRight = scrollbarWidth + 'px';
if (this.isEditMode) this.loadArticle();
else this.resetForm();
},
beforeDestroy() {
document.documentElement.style.overflow = '';
document.documentElement.style.paddingRight = '';
},
methods: {
async loadArticle() {
this.loading = true;
try {
const res = await axios.get(`${window.TT_CONFIG.BASE_PATH}/WarehouseArticle/getById`, {
params: { id: Number(this.id) }
});
const data = res.data;
if (data && data.id) {
this.formData = {
title: data.title || '',
description: data.description || '',
category_id: data.category_id,
articleNumber: data.articleNumber || '',
unit: data.unit || 'Stk.',
revenueAccount: data.revenueAccount || 0,
warningAmount: data.warningAmount || 0,
criticalAmount: data.criticalAmount || 0,
isSerialDocumentation: !!data.isSerialDocumentation,
isEndOfLife: !!data.isEndOfLife,
isEShop: !!data.isEShop,
isEShopHide: !!data.isEShopHide,
isSbidiShop: !!data.isSbidiShop,
isSbidiShopHide: !!data.isSbidiShopHide
};
}
} catch (e) {
window.notify('error', 'Fehler beim Laden');
} finally {
this.loading = false;
}
},
resetForm() {
this.formData = {
title: '',
description: '',
category_id: null,
articleNumber: '',
unit: 'Stk.',
revenueAccount: 0,
warningAmount: 0,
criticalAmount: 0,
isSerialDocumentation: false,
isEndOfLife: false,
isEShop: false,
isEShopHide: false,
isSbidiShop: false,
isSbidiShopHide: false
};
},
async onCategoryChange(categoryId) {
if (!categoryId || this.isEditMode) return;
try {
const res = await axios.get(`${window.TT_CONFIG.BASE_PATH}/WarehouseArticle/getNextArticleNumber`, {
params: { categoryId: categoryId }
});
if (res.data.success) {
this.formData.articleNumber = res.data.articleNumber;
}
} catch (e) {
console.error('Failed to get next article number:', e);
}
},
async save() {
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);
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');
} else {
window.notify('error', res.data.message || 'Fehler beim Speichern');
}
} catch (e) {
window.notify('error', 'Fehler beim Speichern');
} finally {
this.saving = false;
}
},
close() {
this.$emit('close');
}
}
});
Vue.component('warehouse-article', {
template: `
<tt-card>
<warehouse-article-modal
v-if="articleModalId"
:id="articleModalId"
@close="articleModalId = null; $refs.table.$refs.table.refreshTable()"
@reopen="articleModalId = $event"/>
<warehouse-history-modal :show.sync="historyModal" :id="historyModalId"/>
<div class="mb-3" v-if="window.TT_CONFIG.WAREHOUSE_ADMIN">
<button @click="articleModalId = 'create'" class="btn btn-primary">
<i class="fas fa-plus me-1"></i>Artikel erstellen
</button>
</div>
<tt-table-crud
ref="table"
emit-edit
@openHistory="historyModalId = $event.id; historyModal = true"
@printLabel="printLabel($event)"
@edit="articleModalId = $event.id">
<template v-slot:cheapestsellprice="{ row }">
<template v-for="price in JSON.parse(row.cheapestSellPrice || '[]')">
<span style="white-space:nowrap;" v-if="price && window.TT_CONFIG['WAREHOUSE_ADMIN']">{{ price.title }}: <span
>{{ price.price }} €</span><br></span>
<span v-else-if="price && price.title === 'Verkauf'">{{ price.price }} €</span>
</template>
</template>
<template v-slot:description="{ row }">
<tt-tooltip
allow-wrapping
v-if="row.description.length > 45"
:text="row.description" position="top">
<span v-if="row.description.length > 45">{{ row.description.substring(0, 45) }}...</span>
</tt-tooltip>
<span v-else>{{ row.description }}</span>
</template>
</tt-table-crud>
</tt-card>
`,
data: () => ({
window,
historyModal: false,
historyModalId: null,
articleModalId: null
}),
mounted() {
const table = this.$refs.table?.$refs?.table;
if (!table) return;
const showId = new URLSearchParams(window.location.search).get('showId');
if (showId && (!table.filters || table.filters.id !== showId)) {
table.filters = {...table.filters, id: showId};
table.refreshTable();
} else if (!showId && table.filters?.id) {
delete table.filters.id;
if (Object.keys(table.filters).length === 0) table.filters = {};
table.refreshTable();
}
},
methods: {
printLabel(event) {
const url = window.TT_CONFIG.BASE_PATH + "/WarehouseArticle/printLabel?id=" + event.id;
window.open(url, '_blank');
}
}
});