352 lines
15 KiB
JavaScript
352 lines
15 KiB
JavaScript
Vue.component('warehouse-offer', {
|
|
template: `
|
|
<tt-card>
|
|
<warehouse-offer-modal v-if="offerModalId" :id="offerModalId" ref="modal"
|
|
@close="closeModal"/>
|
|
<send-mail-modal v-if="sendMailModalId" :offer-id="sendMailModalId" @close="closeModal"/>
|
|
|
|
<div class="d-flex" style="gap: 8px; margin-bottom: 1rem;">
|
|
<tt-button text="Angebot erstellen" @click="offerModalId = 'create'" additional-class="btn-primary"/>
|
|
<div class="dropdown" id="offer-templates-dropdown">
|
|
<button class="btn btn-outline-primary dropdown-toggle" @click.stop="offerTemplatesDropdown = !offerTemplatesDropdown" >
|
|
Vorlage <i class="fas fa-caret-down"></i>
|
|
</button>
|
|
<ul class="dropdown-menu" :class="{'show': offerTemplatesDropdown}" @mouseleave="offerTemplatesDropdown = false">
|
|
<li v-for="template in offerTemplates" @click="createOfferFromTemplate(template)" style="display: flex; gap: 2px;cursor: pointer;margin-bottom: 4px;margin-right: 4px">
|
|
<a class="dropdown-item">{{ template.templateName }}</a>
|
|
<tt-button
|
|
v-if="window.TT_CONFIG['WAREHOUSE_ADMIN'] === '1'"
|
|
icon="fas fa-trash" sm additional-class="btn-danger" @click.stop="deleteTemplate(template.id)"/>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
|
|
<tt-table-crud emit-edit
|
|
@edit="offerModalId = $event.id"
|
|
ref="table"
|
|
@openpdf="openPDF($event)"
|
|
@sendmail="sendMailModalId = $event.id"
|
|
>
|
|
<template v-slot:expandedRow="{ row }">
|
|
<warehouse-offer-detail :id="row.id"/>
|
|
</template>
|
|
<template v-slot:totalamount="{ row }">{{ formatPrice(row.totalAmount) }}</template>
|
|
<template v-slot:create="{ row }">{{ formatDate(row.create) }}</template>
|
|
<template v-slot:lastsentdate="{ row }">{{ formatDate(row.lastSentDate) }}</template>
|
|
</tt-table-crud>
|
|
</tt-card>
|
|
`,
|
|
data() {
|
|
return {
|
|
window: window,
|
|
offerModalId: null,
|
|
sendMailModalId: null,
|
|
offerTemplates: [],
|
|
offerTemplatesDropdown: false,
|
|
}
|
|
},
|
|
async mounted() {
|
|
await this.loadTemplates();
|
|
document.addEventListener('click', this.closeDropdown);
|
|
},
|
|
beforeDestroy() {
|
|
document.removeEventListener('click', this.closeDropdown);
|
|
},
|
|
methods: {
|
|
formatDate: ts => ts ? window.moment(ts * 1000).format('DD.MM.YYYY') : '-',
|
|
formatPrice: price => new Intl.NumberFormat('de-AT', { style: 'currency', currency: 'EUR' }).format(price || 0),
|
|
async closeModal() {
|
|
this.offerModalId = null;
|
|
this.sendMailModalId = null;
|
|
await new Promise(resolve => setTimeout(resolve, 250));
|
|
this.$refs.table.$refs.table.refreshTable();
|
|
},
|
|
async loadTemplates() {
|
|
const response = await axios.get(`${window.TT_CONFIG["BASE_PATH"]}/WarehouseOffer/getTemplates`);
|
|
this.offerTemplates = response.data;
|
|
},
|
|
openPDF(offer) {
|
|
window.open(`${window.TT_CONFIG['BASE_PATH']}/WarehouseOffer/createPDF?id=${offer.id}&version=${offer.version}`)
|
|
},
|
|
closeDropdown(event) {
|
|
if (!event.target.closest('#offer-templates-dropdown')) {
|
|
this.offerTemplatesDropdown = false;
|
|
}
|
|
},
|
|
async createOfferFromTemplate(template) {
|
|
this.offerTemplatesDropdown = false;
|
|
this.offerModalId = 'create';
|
|
await this.$nextTick();
|
|
this.$refs.modal.offer.positions = JSON.parse(template.positions);
|
|
this.$refs.modal.offer.totalDiscount = template.totalDiscount;
|
|
this.$refs.modal.offer.paymentTerms = template.paymentTerms;
|
|
this.$refs.modal.offer.deliveryTerms = template.deliveryTerms;
|
|
this.$refs.modal.offer.closingText = template.closingText;
|
|
this.$refs.modal.offer.notes = template.notes;
|
|
window.notify('success', 'Angebot aus Vorlage erstellt');
|
|
},
|
|
async deleteTemplate(id) {
|
|
if(!confirm('Vorlage wirklich löschen?')) return;
|
|
const response = await axios.get(`${window.TT_CONFIG["BASE_PATH"]}/WarehouseOffer/deleteTemplate?id=${id}`);
|
|
if (response.data.success) {
|
|
await this.loadTemplates();
|
|
window.notify('success', 'Vorlage erfolgreich gelöscht');
|
|
} else {
|
|
window.notify('error', response.data.message || 'Ein Fehler ist aufgetreten');
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
Vue.component('warehouse-offer-detail', {
|
|
template: `
|
|
<div class="offer-detail-container">
|
|
<div v-if="loading" class="text-center p-5"><i class="fas fa-spinner fa-spin fa-2x"></i></div>
|
|
<div v-else class="offer-detail-grid">
|
|
<!-- Left Column: Offer Info -->
|
|
<div class="offer-info-pane">
|
|
<h5 class="pane-title"><i class="fas fa-info-circle mr-2"></i>Übersicht</h5>
|
|
<div class="info-grid">
|
|
<div class="info-item">
|
|
<label>Kunde</label>
|
|
<span>{{ offer.customerName }}</span>
|
|
</div>
|
|
<div class="info-item">
|
|
<label>Zweck</label>
|
|
<span>{{ offer.purpose || '-' }}</span>
|
|
</div>
|
|
<div class="info-item">
|
|
<label>Summe</label>
|
|
<span>{{ formatPrice(offer.totalAmount) }}</span>
|
|
</div>
|
|
<div class="info-item">
|
|
<label>Status</label>
|
|
<span class="badge" :class="statusInfo.badgeClass">{{ statusInfo.text }}</span>
|
|
</div>
|
|
<div class="info-item">
|
|
<label>Sachbearbeiter</label>
|
|
<span>{{ editorName }}</span>
|
|
</div>
|
|
<div class="info-item">
|
|
<label>Erstellt am</label>
|
|
<span>{{ formatDate(offer.create, 'DD.MM.YYYY') }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Right Column: Journal -->
|
|
<div class="offer-journal-pane">
|
|
<h5 class="pane-title"><i class="fas fa-history mr-2"></i>Journal</h5>
|
|
<div class="journal-box mb-3">
|
|
<div v-if="journal.length === 0" class="text-center text-muted p-4">Noch keine Einträge vorhanden.</div>
|
|
<div v-for="log in journal" class="journal-entry-styled">
|
|
<div class="journal-avatar" :style="{ backgroundColor: userColor(log.createByName) }">
|
|
<span>{{ log.createByName ? log.createByName.charAt(0) : '?' }}</span>
|
|
</div>
|
|
<div class="journal-content">
|
|
<div class="journal-header">
|
|
<strong>{{ log.createByName }}</strong>
|
|
<span class="text-muted text-xs ml-auto">{{ formatDate(log.create, 'relative') }}</span>
|
|
</div>
|
|
<p class="journal-message" v-if="log.message">{{ log.message }}</p>
|
|
<div v-if="log.fileIds && JSON.parse(log.fileIds).length > 0" class="mt-2">
|
|
<tt-file v-for="fileId in JSON.parse(log.fileIds)" :key="fileId" :id="fileId"/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="new-journal-entry">
|
|
<tt-textarea v-model="newMessage" placeholder="Neuer Journaleintrag..." no-form-group class="flex-grow-1" rows="2"/>
|
|
<div class="new-journal-actions">
|
|
<!-- <tt-file-upload-light ref="fileUpload" multiple @uploaded="onFileUploaded" button-text="" /> -->
|
|
<tt-button text="Speichern" @click="addJournalEntry" :loading="savingJournal" additional-class="btn-primary" sm/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`,
|
|
props: ['id'],
|
|
data: () => ({
|
|
offer: {},
|
|
journal: [],
|
|
loading: true,
|
|
savingJournal: false,
|
|
newMessage: '',
|
|
uploadedFiles: [],
|
|
userColors: {}
|
|
}),
|
|
async mounted() {
|
|
const [offerResponse, journalResponse] = await Promise.all([
|
|
axios.get(`${window.TT_CONFIG.BASE_PATH}/WarehouseOffer/getById`, {params: {id: this.id}}),
|
|
axios.get(`${window.TT_CONFIG.BASE_PATH}/WarehouseOffer/getJournal`, {params: {id: this.id}})
|
|
]);
|
|
this.offer = offerResponse.data;
|
|
this.journal = journalResponse.data.sort((a,b) => b.create - a.create); // Ensure descending order
|
|
this.loading = false;
|
|
},
|
|
methods: {
|
|
formatDate(ts, format = 'DD.MM.YYYY HH:mm') {
|
|
if (!ts) return '-';
|
|
if (format === 'relative') {
|
|
return window.moment(ts * 1000).fromNow();
|
|
}
|
|
return window.moment(ts * 1000).format(format);
|
|
},
|
|
formatPrice: price => new Intl.NumberFormat('de-AT', { style: 'currency', currency: 'EUR' }).format(price || 0),
|
|
onFileUploaded(file) {
|
|
this.uploadedFiles.push(file.id);
|
|
},
|
|
async addJournalEntry() {
|
|
if (!this.newMessage && this.uploadedFiles.length === 0) return;
|
|
this.savingJournal = true;
|
|
try {
|
|
await axios.post(`${window.TT_CONFIG.BASE_PATH}/WarehouseOffer/addJournalEntry`, {
|
|
id: this.id,
|
|
message: this.newMessage,
|
|
fileIds: this.uploadedFiles
|
|
});
|
|
this.newMessage = '';
|
|
this.uploadedFiles = [];
|
|
if (this.$refs.fileUpload) this.$refs.fileUpload.reset();
|
|
|
|
const journalResponse = await axios.get(`${window.TT_CONFIG.BASE_PATH}/WarehouseOffer/getJournal`, {params: {id: this.id}});
|
|
this.journal = journalResponse.data.sort((a,b) => b.create - a.create);
|
|
window.notify('success', 'Journaleintrag gespeichert.');
|
|
} catch (e) {
|
|
window.notify('error', 'Fehler beim Speichern des Eintrags.');
|
|
} finally {
|
|
this.savingJournal = false;
|
|
}
|
|
},
|
|
userColor(userName) {
|
|
if (!userName) return '#cccccc';
|
|
if (this.userColors[userName]) return this.userColors[userName];
|
|
|
|
let hash = 0;
|
|
for (let i = 0; i < userName.length; i++) {
|
|
hash = userName.charCodeAt(i) + ((hash << 5) - hash);
|
|
}
|
|
let color = '#';
|
|
for (let i = 0; i < 3; i++) {
|
|
let value = (hash >> (i * 8)) & 0xFF;
|
|
color += ('00' + value.toString(16)).substr(-2);
|
|
}
|
|
this.userColors[userName] = color;
|
|
return color;
|
|
}
|
|
},
|
|
computed: {
|
|
editorName() {
|
|
const users = window.TT_CONFIG.CRUD_CONFIG.columns.find(c => c.key === 'editor')?.modal?.items || [];
|
|
const user = users.find(u => u.value == this.offer.editor);
|
|
return user ? user.text : 'Unbekannt';
|
|
},
|
|
statusInfo() {
|
|
const statusMap = {
|
|
new: { text: 'Neu', badgeClass: 'badge-primary' },
|
|
sent: { text: 'Ausgeschickt', badgeClass: 'badge-info' },
|
|
accepted: { text: 'Angenommen', badgeClass: 'badge-success' },
|
|
rejected: { text: 'Abgelehnt', badgeClass: 'badge-danger' },
|
|
cancelled: { text: 'Storniert', badgeClass: 'badge-secondary' },
|
|
};
|
|
return statusMap[this.offer.status] || { text: this.offer.status, badgeClass: 'badge-light' };
|
|
}
|
|
}
|
|
});
|
|
;
|
|
|
|
Vue.component('send-mail-modal', {
|
|
props: ['offerId'],
|
|
template: `
|
|
<tt-modal :show="true" title="Angebot per E-Mail senden" @close="$emit('close')" @submit="sendEmail" :save-loading="loading">
|
|
<tt-input label="Empfänger E-Mail" v-model="email" required/>
|
|
<tt-input label="Betreff" v-model="subject" required/>
|
|
<tt-textarea label="Nachricht" v-model="body" rows="5" required/>
|
|
</tt-modal>
|
|
`,
|
|
data() {
|
|
return {
|
|
loading: false,
|
|
email: '',
|
|
subject: '',
|
|
body: 'Sehr geehrte Damen und Herren,\n\nanbei erhalten Sie das angeforderte Angebot.\n\nMit freundlichen Grüßen\nIhr Team der XINON GmbH'
|
|
}
|
|
},
|
|
async mounted() {
|
|
const response = await axios.get(`${window.TT_CONFIG.BASE_PATH}/WarehouseOffer/getById`, {params: {id: this.offerId}});
|
|
const offer = response.data;
|
|
this.email = offer.contactPersonEmail || '';
|
|
this.subject = `Angebot ${offer.offerNumber} von XINON GmbH`;
|
|
},
|
|
methods: {
|
|
async sendEmail() {
|
|
this.loading = true;
|
|
try {
|
|
const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WarehouseOffer/sendOfferEmail`, {
|
|
id: this.offerId,
|
|
email: this.email,
|
|
subject: this.subject,
|
|
body: this.body
|
|
});
|
|
if (response.data.success) {
|
|
window.notify('success', response.data.message);
|
|
this.$emit('close');
|
|
} else {
|
|
window.notify('error', response.data.message);
|
|
}
|
|
} catch (e) {
|
|
window.notify('error', 'E-Mail Versand fehlgeschlagen.');
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
Vue.component('closing-text-modal', {
|
|
template: `
|
|
<tt-modal :show="true" title="Schlusstext-Vorlage wählen" @close="$emit('close')" :submit-button="false">
|
|
<div class="list-group">
|
|
<a href="#" v-for="template in templates" :key="template.id" class="list-group-item list-group-item-action" @click.prevent="$emit('select', template.text)">
|
|
{{ template.name }}
|
|
</a>
|
|
</div>
|
|
<hr v-if="window.TT_CONFIG['WAREHOUSE_ADMIN'] === '1'">
|
|
<div v-if="window.TT_CONFIG['WAREHOUSE_ADMIN'] === '1'">
|
|
<h5>Neue Vorlage erstellen</h5>
|
|
<tt-input label="Name" v-model="newTemplate.name" sm/>
|
|
<tt-textarea label="Text" v-model="newTemplate.text" sm rows="4"/>
|
|
<tt-button text="Vorlage speichern" @click="saveTemplate" sm/>
|
|
</div>
|
|
</tt-modal>
|
|
`,
|
|
data() {
|
|
return {
|
|
templates: [],
|
|
newTemplate: { name: '', text: '' },
|
|
window: window,
|
|
}
|
|
},
|
|
async mounted() {
|
|
await this.loadTemplates();
|
|
},
|
|
methods: {
|
|
async loadTemplates() {
|
|
const response = await axios.get(`${window.TT_CONFIG.BASE_PATH}/WarehouseOffer/getClosingTexts`);
|
|
this.templates = response.data;
|
|
},
|
|
async saveTemplate() {
|
|
if (!this.newTemplate.name || !this.newTemplate.text) {
|
|
return window.notify('error', 'Name und Text dürfen nicht leer sein.');
|
|
}
|
|
await axios.post(`${window.TT_CONFIG.BASE_PATH}/WarehouseOffer/createClosingText`, this.newTemplate);
|
|
this.newTemplate.name = '';
|
|
this.newTemplate.text = '';
|
|
await this.loadTemplates();
|
|
window.notify('success', 'Vorlage gespeichert.');
|
|
}
|
|
}
|
|
});
|