Merge branch 'WarehouseArticle/improve-modal' into 'master'

Warehouse article/improve modal

See merge request fronk/thetool!1937
This commit is contained in:
Luca Haid
2025-12-05 13:57:39 +00:00
5 changed files with 1057 additions and 175 deletions

View File

@@ -241,12 +241,13 @@ class Termination extends mfBaseModel {
$this->log->debug("is range");
$port_parts = explode("-", $ports);
if(is_array($port_parts) && count($port_parts) == 2) {
$from = $port_parts[0];
$to = $port_parts[1];
$from = intval($port_parts[0]);
$to = intval($port_parts[1]);
if($port_parts[0] > $port_parts[1]) {
$from = $port_parts[1];
$to = $port_parts[0];
if($from > $to) {
$tmp = $from;
$from = $to;
$to = $tmp;
}
$range = [];

View File

@@ -1,43 +1,464 @@
/* End of Life Row Highlighting */
.end-of-life {
background-color: #f8d7da !important;
}
.warehouse-article-prices > div,
.warehouse-article-distributor > div {
/*
* Modal Layout
*/
.modal-body {
overflow-x: hidden;
}
.wa-modal-content {
display: flex;
flex-direction: column;
gap: 16px;
overflow-x: hidden;
max-width: 100%;
}
.wa-modal-content .row {
margin-left: 0;
margin-right: 0;
}
.wa-modal-content .row > [class*="col-"] {
padding-left: 8px;
padding-right: 8px;
}
.wa-section {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 6px;
padding: 12px;
}
.wa-section-title {
font-size: 0.9rem;
font-weight: 600;
color: #495057;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #dee2e6;
}
/*
* Article Prices Section - Dense 2-Column Layout
*/
.wa-prices-section {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 6px;
padding: 12px;
}
.wa-prices-grid-dense {
display: grid;
grid-template-columns: repeat(3, minmax(150px, 1fr)) 120px;
gap: 12px;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.wa-price-item {
background: white;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 8px;
transition: box-shadow 0.15s ease;
}
.wa-price-item:hover {
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.wa-price-header {
display: flex;
align-items: center;
padding-bottom: 6px;
border-bottom: 1px solid #e9ecef;
margin-bottom: 8px;
}
.warehouse-article-prices .form-group,
.warehouse-article-distributor .form-group {
.wa-price-body {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 6px;
align-items: center;
}
.wa-price-actions {
display: flex;
align-items: center;
gap: 3px;
}
.wa-price-actions .btn {
padding: 4px 8px;
font-size: 0.75rem;
min-width: 32px;
}
.wa-price-actions .btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/*
* Section Title with Action Button
*/
.wa-section-title-with-action {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
margin: -12px -12px 12px -12px;
background: linear-gradient(to bottom, #ffffff, #f8f9fa);
border-bottom: 1px solid #e9ecef;
border-radius: 6px 6px 0 0;
}
/*
* Distributor Section - With Modal Directory
*/
.wa-distributors-section {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 6px;
padding: 12px;
}
.wa-directory-header {
display: flex;
align-items: center;
padding: 0;
}
.wa-directory-list {
max-height: 300px;
overflow-y: auto;
padding: 8px;
}
.wa-directory-group {
margin-bottom: 12px;
}
.wa-directory-group:last-child {
margin-bottom: 0;
}
@media (max-width: 992px) {
.warehouse-article-prices > div,
.warehouse-article-distributor > div {
display: block;
border: 1px solid #eee;
padding: 10px;
.wa-directory-letter {
font-size: 0.9rem;
font-weight: 700;
color: #007bff;
padding: 4px 8px;
background: #e7f3ff;
border-left: 3px solid #007bff;
margin-bottom: 6px;
}
.wa-directory-items {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 6px;
padding-left: 8px;
}
.wa-directory-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
background: white;
border: 1px solid #ced4da;
border-radius: 4px;
font-size: 0.8rem;
cursor: pointer;
transition: all 0.15s ease;
color: #495057;
}
.wa-directory-item:hover:not(:disabled) {
background: #007bff;
border-color: #007bff;
color: white;
}
.wa-directory-item.active {
background: #28a745;
border-color: #28a745;
color: white;
cursor: default;
}
.wa-directory-item:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.wa-distributors-grid {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 12px;
}
.wa-distributor-row {
display: grid;
grid-template-columns: 180px 1fr auto;
gap: 10px;
align-items: center;
background: white;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 8px;
transition: box-shadow 0.15s ease;
}
.wa-distributor-row:hover {
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.wa-distributor-name {
font-size: 0.8rem;
color: #495057;
font-weight: 500;
display: flex;
align-items: center;
}
.wa-distributor-inputs {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.wa-distributor-actions {
display: flex;
align-items: center;
gap: 3px;
}
.wa-distributor-actions .btn {
padding: 4px 8px;
font-size: 0.75rem;
min-width: 32px;
}
.wa-distributor-actions .btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
/*
* Custom Checkboxes - Similar to WorkorderMphBase
*/
.wa-checkbox-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-top: 8px;
}
.wa-checkbox-item {
display: flex;
align-items: center;
padding: 8px 10px;
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
user-select: none;
}
.wa-checkbox-item:hover {
background: #e9ecef;
border-color: #ced4da;
}
.wa-checkbox-item input[type="checkbox"] {
position: absolute;
opacity: 0;
cursor: pointer;
}
.wa-checkmark {
position: relative;
height: 16px;
width: 16px;
min-width: 16px;
background-color: #fff;
border: 2px solid #adb5bd;
border-radius: 3px;
margin-right: 8px;
transition: all 0.2s ease;
}
.wa-checkbox-item input[type="checkbox"]:checked ~ .wa-checkmark {
background-color: #28a745;
border-color: #28a745;
}
.wa-checkmark:after {
content: "";
position: absolute;
display: none;
left: 4px;
top: 0px;
width: 4px;
height: 8px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.wa-checkbox-item input[type="checkbox"]:checked ~ .wa-checkmark:after {
display: block;
}
.wa-checkbox-label {
font-size: 0.8rem;
line-height: 1.2;
}
/*
* Form Controls - Dense Sizing (Desktop Only)
*/
@media (min-width: 768px) {
.wa-modal-content .form-group {
margin-bottom: 10px;
}
.warehouse-article-prices > div > *,
.warehouse-article-distributor > div > * {
display: block;
margin-bottom: 10px !important;
.wa-modal-content label {
font-size: 0.8rem;
font-weight: 500;
color: #495057;
margin-bottom: 3px;
}
.warehouse-article-prices > div > div:last-child,
.warehouse-article-distributor > div > div:last-child {
text-align: right;
margin-bottom: 0 !important;
.wa-modal-content .form-control-sm,
.wa-modal-content .tt-select-trigger.sm {
height: 30px;
font-size: 0.8rem;
padding: 4px 8px;
}
.warehouse-article-prices > div > div:last-child > *,
.warehouse-article-distributor > div > div:last-child > * {
margin-left: 5px;
/* Ensure tt-select aligns with tt-input */
.wa-modal-content .tt-select-modern {
display: flex;
flex-direction: column;
}
}
.wa-modal-content .tt-select-modern .form-group {
display: flex;
flex-direction: column;
margin-bottom: 10px;
}
.wa-modal-content .tt-select-modern label {
order: -1;
}
.wa-modal-content textarea.form-control {
min-height: 60px;
font-size: 0.8rem;
padding: 6px 8px;
}
}
/*
* Additional Attributes Section
*/
.wa-attributes-section {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 6px;
padding: 12px;
}
.wa-attributes-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
/*
* Responsive Design
*/
@media (max-width: 992px) {
.wa-prices-grid-dense {
grid-template-columns: 1fr;
}
.wa-directory-items {
grid-template-columns: 1fr;
}
.wa-checkbox-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.wa-modal-content .row > [class*="col-"] {
padding-left: 12px;
padding-right: 12px;
}
.wa-price-body {
grid-template-columns: 1fr;
gap: 8px;
}
.wa-price-actions {
justify-content: flex-end;
padding-top: 4px;
border-top: 1px solid #e9ecef;
}
.wa-distributor-row {
grid-template-columns: 1fr;
gap: 8px;
}
.wa-distributor-name {
padding-bottom: 4px;
border-bottom: 1px solid #e9ecef;
}
.wa-distributor-inputs {
grid-template-columns: 1fr;
}
.wa-distributor-actions {
justify-content: flex-end;
padding-top: 4px;
border-top: 1px solid #e9ecef;
}
.wa-checkbox-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 576px) {
.wa-section-title {
font-size: 0.85rem;
}
.wa-modal-content {
gap: 10px;
}
.wa-section {
padding: 10px;
}
.wa-section-title-with-action {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
}

View File

@@ -16,29 +16,55 @@ async function handleApiResponse(responsePromise) {
Vue.component('warehouse-article-prices', {
props: {id: {type: Number, required: true}},
template: `
<tt-card>
<h4 style="text-align: center">Artikelpreise überschreiben</h4>
<div class="warehouse-article-prices">
<div v-for="(price, typeTitle) in articlePrices" :key="typeTitle">
<div style="align-self: center;">
<i v-if="price.isRobot" class="fa-solid fa-robot"></i> {{ typeTitle }}
<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>
<tt-input sm v-model="price.priceMultiplier" label="Preisfaktor" @input="price.priceOverride = null;price.pendingChanges = true"/>
<tt-input sm v-model="price.priceOverride" label="Preis" @input="price.priceMultiplier = null;price.pendingChanges = true"/>
<div style="align-self: end;display: flex;align-items: center;">
<tt-button sm icon="fa-solid fa-save" additional-class="btn-primary" @click="savePrice(price)"/>
<tt-button sm icon="fa-solid fa-trash" additional-class="btn-danger" v-if="!price.isRobot" @click="deletePrice(price)"/>
<i v-if="price.pendingChanges" class="fa-solid fa-triangle-exclamation ml-1 text-warning" style="font-size: 28px" title="Dieser Preis wurde noch nicht gespeichert"></i>
<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>
</tt-card>
</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}}),
@@ -53,7 +79,8 @@ Vue.component('warehouse-article-prices', {
});
pricesRes.data.rows.forEach(pData => {
const type = typesRes.data.rows.find(t => t.id === pData.articlePriceTypeId);
if (type) prices[type.title] = {
if (!type) return;
prices[type.title] = {
id: pData.id,
isRobot: false,
pendingChanges: false,
@@ -71,13 +98,10 @@ Vue.component('warehouse-article-prices', {
priceMultiplier: price.priceMultiplier ? parseFloat(price.priceMultiplier.toString().replace(',', '.')) : null,
priceOverride: price.priceOverride ? parseFloat(price.priceOverride.toString().replace(',', '.')) : null
};
if (price.isRobot) {
await this.window.handleApiResponse(axios.post(`${window['TT_CONFIG']['BASE_PATH']}/WarehouseArticlePrice/create`, payload));
await this.fetchArticlePrices();
} else {
await this.window.handleApiResponse(axios.post(`${window['TT_CONFIG']['BASE_PATH']}/WarehouseArticlePrice/update`, {id: price.id, ...payload}));
await this.fetchArticlePrices();
}
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}
@@ -87,42 +111,187 @@ Vue.component('warehouse-article-prices', {
}
});
// warehouse-article-distributor.vue.js
Vue.component('warehouse-article-distributor', {
props: {id: {type: Number, required: true}},
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: `
<tt-card>
<h4 style="text-align: center">Lieferanten für diesen Artikel</h4>
<tt-autocomplete :api-url="window['TT_CONFIG']['BASE_PATH'] + '/WarehouseDistributor/autocomplete'"
v-model="newDistributorId" label="Neuen Lieferant hinzufügen"/>
<div class="warehouse-article-distributor">
<div v-for="(distributor, index) in articleDistributors" :key="distributor.id || ('new-' + index)">
<tt-resolver style="align-self: center;" reference="WarehouseDistributor"
:value="distributor.distributorId"></tt-resolver>
<tt-input sm v-model="distributor.externalArticleNumber" label="Externe Artikelnummer" @input="distributor.pendingChanges = true"/>
<tt-input sm v-model="distributor.purchasePrice" label="Einkaufspreis" @input="distributor.pendingChanges = true"/>
<div style="align-self: end; display: flex;align-items: center;">
<tt-button sm icon="fa-solid fa-save" additional-class="btn-primary"
@click="saveDistributor(distributor)"/>
<tt-button sm icon="fa-solid fa-trash" additional-class="btn-danger" v-if="distributor.id"
@click="deleteDistributor(distributor.id)"/>
<i v-if="distributor.pendingChanges || !distributor.id" class="fa-solid fa-triangle-exclamation ml-1 text-warning"
style="font-size: 28px" title="Dieser Preis wurde noch nicht gespeichert"></i>
<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>
</tt-card>
</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: [], newDistributorId: null}),
data: () => ({
window,
articleDistributors: [],
allDistributors: [],
showDirectoryModal: false
}),
async mounted() {
await this.fetchArticleDistributors();
await Promise.all([
this.fetchArticleDistributors(),
this.fetchAllDistributors()
]);
},
methods: {
async fetchAllDistributors() {
const res = await axios.post(`${window['TT_CONFIG']['BASE_PATH']}/WarehouseDistributor/get`, {
pagination: false,
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;
@@ -130,34 +299,330 @@ Vue.component('warehouse-article-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();
}
},
watch: {
newDistributorId(newId) {
if (newId) {
if (!this.articleDistributors.some(d => d.distributorId === newId)) {
this.articleDistributors.push({
articleId: this.id,
distributorId: newId,
externalArticleNumber: null,
purchasePrice: null
});
}
this.$nextTick(() => {
this.newDistributorId = null;
});
}
}
}
});
// warehouse-article.vue.js
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">
<tt-input
label="Titel"
v-model="formData.title"
placeholder="Artikel Titel"
required
sm/>
</div>
<div class="col-md-12">
<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"
required
sm/>
</div>
<div class="col-md-4">
<tt-input
label="Artikel-Nummer"
v-model="formData.articleNumber"
placeholder="z.B. 1234"
required
form-label
sm/>
</div>
<div class="col-md-2">
<tt-select
label="Einheit"
v-model="formData.unit"
:options="unitOptions"
required
sm/>
</div>
<div class="col-md-2">
<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">
<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 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>
<tt-table-crud ref="table" @openHistory="historyModalId = $event.id; historyModal = true">
<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"/>
<tt-table-crud
ref="table"
emit-edit
@openHistory="historyModalId = $event.id; historyModal = true"
@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
@@ -165,7 +630,7 @@ Vue.component('warehouse-article', {
<span v-else-if="price && price.title === 'Verkauf'">{{ price.price }} €</span>
</template>
</template>
<template v-slot:description="{ row }">
<tt-tooltip
allow-wrapping
@@ -175,16 +640,15 @@ Vue.component('warehouse-article', {
</tt-tooltip>
<span v-else>{{ row.description }}</span>
</template>
<template v-slot:modal-prepend="{ crudModalData }">
<warehouse-article-prices v-if="crudModalData.id" :id="crudModalData.id"/>
<warehouse-article-distributor v-if="crudModalData.id" :id="crudModalData.id"/>
</template>
</tt-table-crud>
<warehouse-history-modal :show.sync="historyModal" :id="historyModalId"/>
</tt-card>
`,
data: () => ({window, historyModal: false, historyModalId: null, articleTest: null}),
data: () => ({
window,
historyModal: false,
historyModalId: null,
articleModalId: null
}),
mounted() {
const table = this.$refs.table?.$refs?.table;
if (!table) return;
@@ -197,9 +661,5 @@ Vue.component('warehouse-article', {
if (Object.keys(table.filters).length === 0) table.filters = {};
table.refreshTable();
}
window.addEventListener('refreshTable', () => {
table.refreshTable();
});
}
});
});

View File

@@ -6,6 +6,7 @@ Vue.component('tt-input', {
placeholder: String,
required: Boolean,
row: Boolean,
formLabel: Boolean,
value: [String, Number],
hint: String,
additionalProps: Object,
@@ -27,7 +28,7 @@ Vue.component('tt-input', {
<div :class="{'row': row, 'form-group' : !noFormGroup, 'tt-input-with-icon': prefixIcon}">
<slot name="prepend"></slot>
<label
:class="{'col-form-label': row, 'col-sm-4': row, 'col-form-label-sm': sm && row}"
:class="{'col-form-label': row || formLabel, 'col-sm-4': row, 'col-form-label-sm': sm && row}"
v-if="label"
:for="label">{{ label }}</label>
<i v-if="prefixIcon" :class="['prefix-icon', prefixIcon]"></i>

View File

@@ -1,90 +1,89 @@
Vue.component('tt-modal', {
props: {
show: {type: [Boolean, Object], default: false},
title: {type: String, default: 'Überschrift'},
delete: {type: Boolean, default: true},
deleteText: {type: String, default: 'Löschen'},
save: {type: Boolean, default: true},
props: {
show: {type: [Boolean, Object], default: false},
title: {type: String, default: 'Überschrift'},
delete: {type: Boolean, default: true},
deleteText: {type: String, default: 'Löschen'},
save: {type: Boolean, default: true},
saveLoading: {type: Boolean, default: false},
saveText: {type: String, default: 'Speichern'},
disableMinHeight: {type: Boolean, default: false},
}, watch: {
saveText: {type: String, default: 'Speichern'},
disableMinHeight: {type: Boolean, default: false}
},
watch: {
show(newVal) {
if (!newVal) {
this.$emit('close')
document.documentElement.style.overflow = ''
document.documentElement.style.paddingRight = ''
}
if (newVal) {
const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth
document.documentElement.style.overflow = 'hidden'
document.documentElement.style.paddingRight = scrollbarWidth + 'px'
this.$nextTick(() => {
const input = this.$refs.modal.querySelector('input')
if (input) {
if (input.classList.contains('tt-autocomplete')) {
return
}
input.focus()
}
if (input && !input.classList.contains('tt-autocomplete')) input.focus()
})
}
},
}, // create global listener for esc + return key (if save is enabled)
}
},
created() {
document.addEventListener('keydown', this.keydownHandler)
}, destroyed() {
},
destroyed() {
document.removeEventListener('keydown', this.keydownHandler)
}, methods: {
document.documentElement.style.overflow = ''
document.documentElement.style.paddingRight = ''
},
methods: {
keydownHandler(event) {
if (!this.show) {
return
}
if (event.key === 'Escape') {
this.$emit('update:show', false)
}
if (!this.show) return
if (event.key === 'Escape') this.$emit('update:show', false)
if (event.key === 'Enter' && this.save) {
if (event.target.tagName === 'TEXTAREA' || event.target.tagName === 'INPUT') {
return
}
if (event.target.tagName === 'TEXTAREA' || event.target.tagName === 'INPUT') return
this.$emit('submit')
}
}
},
data() {
return {window: window, isMobile: navigator.userAgent.match(/(iPad)|(iPhone)|(iPod)|(android)|(webOS)/i) !== null}
},
//language=Vue
data: () => ({
window: window,
isMobile: navigator.userAgent.match(/(iPad)|(iPhone)|(iPod)|(android)|(webOS)/i) !== null
}),
template: `
<div class="modal show d-block"
role="dialog"
tabindex="-1"
style="background: rgba(0, 0, 0, 0.5);"
ref="modal"
@keydown.esc="$emit('update:show', false)"
v-if="show">
<div class="modal-dialog modal-lg modal-dialog-scrollable" role="document" @mousedown.stop>
<div class="modal-content" :style="{minHeight: disableMinHeight ? 'auto' : '45vh'}">
<div class="modal-header">
<h5 class="modal-title">{{title}}</h5>
<button type="button" class="close" @click="$emit('update:show', false)">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<slot></slot>
</div>
<div class="modal-footer">
<slot name="footer">
<slot name="footer-prepend"></slot>
<button v-if="save" class="btn btn-primary position-relative" @click="$emit('submit')" :disabled="saveLoading">
<span v-if="!saveLoading">{{ saveText }}</span>
<span v-if="saveLoading" class="top-50 start-50 translate-middle">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span class="sr-only">Loading...</span>
</span>
</button>
<button v-if="$props.delete" class="btn btn-danger" @click="$emit('delete')">{{deleteText}}</button>
<button class="btn btn-secondary" @click="$emit('update:show', false)">Schließen</button>
</slot>
</div>
</div>
</div>
</div>
`
<div class="modal show d-block"
role="dialog"
tabindex="-1"
style="background: rgba(0, 0, 0, 0.5);"
ref="modal"
@keydown.esc="$emit('update:show', false)"
v-if="show">
<div class="modal-dialog modal-lg modal-dialog-scrollable" role="document" @mousedown.stop>
<div class="modal-content" :style="{minHeight: disableMinHeight ? 'auto' : '45vh'}">
<div class="modal-header">
<h5 class="modal-title">{{title}}</h5>
<button type="button" class="close" @click="$emit('update:show', false)">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<slot></slot>
</div>
<div class="modal-footer">
<slot name="footer">
<slot name="footer-prepend"></slot>
<button v-if="save" class="btn btn-primary position-relative" @click="$emit('submit')" :disabled="saveLoading">
<span v-if="!saveLoading">{{ saveText }}</span>
<span v-if="saveLoading" class="top-50 start-50 translate-middle">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span class="sr-only">Loading...</span>
</span>
</button>
<button v-if="$props.delete" class="btn btn-danger" @click="$emit('delete')">{{deleteText}}</button>
<button class="btn btn-secondary" @click="$emit('update:show', false)">Schließen</button>
</slot>
</div>
</div>
</div>
</div>
`
})