Files
thetool/public/js/pages/RMLWorkorderAdmin/RMLWorkorderAdmin.js
2025-08-29 12:30:14 +02:00

601 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// RMLWorkorderAdmin.js
Vue.component('r-m-l-workorder-admin', {
template: `
<tt-card>
<div class="mb-2 d-flex align-items-center" v-if="workordersToAssign.length > 0">
<span class="mr-3 font-weight-bold">{{ workordersToAssign.length }} Workorder(s) zuweisen:</span>
<div style="width: 300px;">
<tt-select
class="mb-0"
:options="companiesForMassAssign"
v-model="massAssignCompanyId"
@input="massAssignCompanies"
placeholder="Firma auswählen..."
sm
no-form-group
/>
</div>
</div>
<tt-table-crud
ref="table"
:crud-config="crudConfig"
>
<template v-slot:preorderinfo="{ row }">
<div class="small">
<div><strong>Kunde:</strong> {{ row.customerCompany || row.customerName }}</div>
<div>
<strong>Anschluss:</strong>
{{ row.street }} {{ row.hausnummer }}
<template v-if="row.stiege">/{{ row.stiege }}</template>
<template v-if="row.apartment"> / WE: {{ row.apartment }}</template>
, {{ row.plz }} {{ row.city }}
</div>
<div>
<strong>OAID:</strong> <span class="text-pink">{{ row.oaid }}</span>
<tt-button
icon="fas fa-external-link-alt"
@click="window.open(window.TT_CONFIG.BASE_PATH + '/Preorder/Index?filter[ucode]=' + row.ucode, '_blank');"
additional-class="btn-link btn-sm p-0 m-0"
title="Zur Bestellung"
/>
</div>
</div>
</template>
<template v-slot:status="{ row }">
<traffic-light :deadline="row.deadlineDate" :status="row.status"/>
<i :class="getStatusColumn(row.status).icon" :title="getStatusColumn(row.status).text"></i>
<span class="ml-2">{{ getStatusColumn(row.status).text }}</span>
<tt-button
v-if="row.status === 'intervention_required'"
icon="ml-2 fas fa-check-circle text-success"
@click="setToProblemSolved(row)"
additional-class="btn-link btn-sm p-0"
title="Auftrag auf Problem behoben setzen"
/>
</template>
<template v-slot:companyname="{ row }">
<div class="d-flex justify-content-between align-items-center">
<div class="flex-grow-1">
<div v-if="editingWorkorderId === row.id">
<div v-if="companiesLoading" class="spinner-border spinner-border-sm"></div>
<tt-select v-else
:options="companiesByTenant[row.tenantId] || []" :value="row.companyId"
@input="assignCompany(row, $event)" @blur="editingWorkorderId = null"
@keydown.esc.native="editingWorkorderId = null" placeholder="Firma zuweisen..."
sm no-form-group
/>
</div>
<div v-else-if="row.status === 'new'">
<div v-if="companiesLoading" class="spinner-border spinner-border-sm"></div>
<tt-select v-else
:options="companiesByTenant[row.tenantId] || []" :value="row.companyId"
@input="assignCompany(row, $event)" @focus="getCompaniesForWorkorder(row)"
placeholder="Firma zuweisen..." sm no-form-group
/>
</div>
<div v-else>
<span>{{ row.companyName || 'N/A' }}</span>
</div>
</div>
<div class="d-flex align-items-center">
<tt-button
v-if="!['completed', 'cancelled', 'new'].includes(row.status)"
icon="fas fa-edit"
@click="startCompanyEdit(row)"
additional-class="btn-link btn-sm p-0 ml-2"
title="Zuweisung ändern"
/>
<tt-button
v-if="!['completed', 'cancelled'].includes(row.status)"
icon="fas fa-ban"
@click="cancelWorkorder(row)"
additional-class="btn-link btn-sm p-0 ml-2 text-danger"
title="Auftrag stornieren"
/>
<tt-button v-if="!workordersToAssign.includes(row.id)"
icon="fas fa-plus-circle text-success" @click="addToAssignList(row)"
additional-class="btn-link btn-sm p-0 ml-2" title="Zur Zuweisungsliste hinzufügen"
/>
<tt-button v-if="workordersToAssign.includes(row.id)"
icon="fas fa-minus-circle text-danger" @click="removeFromAssignList(row)"
additional-class="btn-link btn-sm p-0 ml-2" title="Von Zuweisungsliste entfernen"
/>
</div>
</div>
</template>
<template v-slot:additionalinfo="{ row }">
<div v-if="editingAdditionalInfoId === row.id">
<tt-textarea
v-model="tempAdditionalInfo"
@keydown.esc.native="cancelEdit"
rows="3"
no-form-group
sm
ref="editTextarea"
/>
<div class="mt-2 d-flex justify-content-end">
<tt-button
text="Abbrechen"
@click="cancelEdit"
sm
additional-class="btn-secondary mr-2"
/>
<tt-button
text="Speichern"
@click="updateAdditionalInfo(row, tempAdditionalInfo)"
sm
additional-class="btn-success"
/>
</div>
</div>
<div v-else class="d-flex align-items-start">
<span
style="white-space: pre-wrap; max-width: 250px; display: inline-block;">{{ row.additionalInfo || '-' }}</span>
<tt-button
icon="fas fa-edit" @click="startAdditionalInfoEdit(row)"
additional-class="btn-link btn-sm p-0 ml-2" title="Zusatz-Info bearbeiten"
/>
</div>
</template>
<template v-slot:deadlinedate="{ row }">
<div v-if="editingDeadlineId === row.id">
<tt-date-picker
:value="row.deadlineDate" :date-range="false"
@input="updateDeadline(row, $event)" @blur="editingDeadlineId = null"
sm no-form-group
/>
</div>
<div v-else class="d-flex align-items-center">
<span>{{ formatDate(row.deadlineDate) }}</span>
<tt-button icon="fas fa-edit" @click="editingDeadlineId = row.id"
additional-class="btn-link btn-sm p-0 ml-2" title="Deadline ändern"
/>
</div>
</template>
<template v-slot:appointmentdate="{ row }">
{{ formatDate(row.appointmentDate, true) }}
</template>
<template v-slot:expandedRow="{ row }">
<rml-documentation-viewer-admin
:workorder-id="row.id"
@workorder-updated="$refs.table.$refs.table.refreshTable()"
@accept-documentation="acceptDocumentation"
/>
</template>
</tt-table-crud>
</tt-card>
`,
data() {
return {
window,
workordersToAssign: [],
editingWorkorderId: null,
editingDeadlineId: null,
editingAdditionalInfoId: null,
tempAdditionalInfo: '',
companiesByTenant: {},
companiesLoading: false,
massAssignCompanyId: null,
massAssignLoading: false,
companiesForMassAssign: [],
crudConfig: {
...window.TT_CONFIG.CRUD_CONFIG, selectable: false, expandable: true,
customRowClass: (row) => {
if (['completed', 'new', 'cancelled'].includes(row.status)) return 'tt-rml-workorder-irrelevant';
if (['correction_requested', 'intervention_required'].includes(row.status)) return 'tt-rml-workorder-high';
const deadlineDate = moment.unix(row.deadlineDate);
if (!deadlineDate.isValid()) return 'tt-rml-workorder-irrelevant';
const daysLeft = deadlineDate.diff(moment(), 'days');
if (daysLeft <= 7) return 'tt-rml-workorder-urgent';
if (daysLeft <= 21) return 'tt-rml-workorder-medium';
return 'tt-rml-workorder-ontrack';
},
additionalActions: []
}
}
},
methods: {
addToAssignList(row) {
if (!this.workordersToAssign.includes(row.id)) this.workordersToAssign.push(row.id);
},
removeFromAssignList(row) {
this.workordersToAssign = this.workordersToAssign.filter(id => id !== row.id);
},
getStatusColumn(status) {
const column = this.crudConfig.columns.find(c => c.key === 'status');
return column.table.filterOptions.find(opt => opt.value === status) || {};
},
formatDate(timestamp, withTime = false) {
if (!timestamp) return '';
return window.moment.unix(timestamp).format(withTime ? 'DD.MM.YYYY HH:mm' : 'DD.MM.YYYY');
},
async getCompaniesForWorkorder(workorder) {
if (!workorder.tenantId || this.companiesByTenant[workorder.tenantId]) return;
this.companiesLoading = true;
try {
const {data} = await axios.get(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderAdmin/getCompanies`, {params: {tenantId: workorder.tenantId}});
this.$set(this.companiesByTenant, workorder.tenantId, data);
} catch (e) {
window.notify('error', 'Firmenliste konnte nicht geladen werden.');
} finally {
this.companiesLoading = false;
}
},
async startCompanyEdit(row) {
await this.getCompaniesForWorkorder(row);
this.editingWorkorderId = row.id;
},
async assignCompany(workorder, companyId) {
if (!companyId) {
this.editingWorkorderId = null;
return;
}
try {
const {data} = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderAdmin/assignWorkorder`, {
workorderId: workorder.id,
companyId: companyId
});
if (data.success) {
window.notify('success', data.message);
this.$refs.table.$refs.table.refreshTable();
} else window.notify('error', data.message || 'Ein Fehler ist aufgetreten.');
} catch (e) {
window.notify('error', 'Ein Netzwerkfehler ist aufgetreten.');
} finally {
this.editingWorkorderId = null;
}
},
async massAssignCompanies(companyId) {
if (!companyId) return;
if (!confirm(`${this.workordersToAssign.length} Workorder(s) der ausgewählten Firma zuweisen?`)) {
this.massAssignCompanyId = null;
return;
}
this.massAssignLoading = true;
try {
const {data} = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderAdmin/massAssignWorkorders`, {
companyId: companyId,
workorderIds: this.workordersToAssign
});
if (data.success) {
window.notify('success', data.message);
this.workordersToAssign = [];
this.$refs.table.$refs.table.refreshTable();
} else window.notify('error', data.message || 'Ein Fehler ist aufgetreten.');
} catch (e) {
window.notify('error', 'Ein Netzwerkfehler ist aufgetreten.');
} finally {
this.massAssignLoading = false;
this.massAssignCompanyId = null;
}
},
async updateDeadline(workorder, newDate) {
if (!newDate) {
this.editingDeadlineId = null;
return;
}
try {
const {data} = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderAdmin/updateDeadline`, {
workorderId: workorder.id,
deadlineDate: newDate
});
if (data.success) {
window.notify('success', data.message);
this.$refs.table.$refs.table.refreshTable();
} else window.notify('error', data.message || 'Ein Fehler ist aufgetreten.');
} catch (e) {
window.notify('error', 'Ein Netzwerkfehler ist aufgetreten.');
} finally {
this.editingDeadlineId = null;
}
},
async acceptDocumentation(workorderId) {
if (!confirm('Soll die Dokumentation für diesen Arbeitsauftrag wirklich akzeptiert und der Auftrag abgeschlossen werden?')) return;
try {
const {data} = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderAdmin/acceptDocumentation`, {workorderId});
if (data.success) {
window.notify('success', data.message);
this.$refs.table.$refs.table.refreshTable();
} else window.notify('error', data.message || 'Ein Fehler ist aufgetreten.');
} catch (e) {
window.notify('error', 'Ein Netzwerkfehler ist aufgetreten.');
}
},
async setToProblemSolved(row) {
const text = prompt('Bitte geben Sie einen kurzen Text für den Journaleintrag ein:', '');
if (text === null) return;
if (!text.trim()) {
window.notify('error', 'Bitte geben Sie einen Text ein.');
return;
}
try {
const {data} = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderAdmin/setToProblemSolved`, {
workorderId: row.id,
text: text
});
if (data.success) {
window.notify('success', data.message);
this.$refs.table.$refs.table.refreshTable();
} else window.notify('error', data.message || 'Ein Fehler ist aufgetreten.');
} catch (e) {
window.notify('error', 'Ein Netzwerkfehler ist aufgetreten.');
}
},
startAdditionalInfoEdit(row) {
this.editingAdditionalInfoId = row.id;
this.tempAdditionalInfo = row.additionalInfo || '';
this.$nextTick(() => {
this.$refs.editTextarea?.$el.querySelector('textarea').focus();
});
},
cancelEdit() {
this.editingAdditionalInfoId = null;
this.tempAdditionalInfo = '';
},
async updateAdditionalInfo(row, newInfo) {
if (row.additionalInfo === newInfo) {
this.cancelEdit();
return;
}
try {
const {data} = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderAdmin/updateAdditionalInfo`, {
workorderId: row.id,
additionalInfo: newInfo
});
if (data.success) {
window.notify('success', data.message);
row.additionalInfo = newInfo;
} else window.notify('error', data.message || 'Update fehlgeschlagen.');
} catch (e) {
window.notify('error', 'Netzwerkfehler beim Update.');
} finally {
this.cancelEdit();
}
},
async cancelWorkorder(row) {
const reason = prompt('Bitte geben Sie einen Grund für die Stornierung an (optional):');
if (reason === null) return;
try {
const {data} = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderAdmin/cancelWorkorder`, {
workorderId: row.id,
reason: reason
});
if (data.success) {
window.notify('success', data.message);
this.$refs.table.$refs.table.refreshTable();
} else window.notify('error', data.message || 'Stornierung fehlgeschlagen.');
} catch (e) {
window.notify('error', 'Ein Netzwerkfehler ist aufgetreten.');
}
}
},
watch: {
workordersToAssign: {
async handler(newVal) {
if (newVal.length === 0) {
this.companiesForMassAssign = [];
return;
}
const firstWorkorder = this.$refs.table.$refs.table.rows.find(r => r.id === newVal[0]);
if (!firstWorkorder) return;
const firstTenantId = firstWorkorder.tenantId;
if (!newVal.every(id => {
const wo = this.$refs.table.$refs.table.rows.find(r => r.id === id);
return wo && wo.tenantId === firstTenantId;
})) {
window.notify('error', 'Massen-Zuweisung nur für Aufträge des gleichen Mandanten möglich.');
this.workordersToAssign.pop();
return;
}
await this.getCompaniesForWorkorder(firstWorkorder);
this.companiesForMassAssign = this.companiesByTenant[firstTenantId] || [];
},
deep: true
}
}
});
Vue.component('traffic-light', {
props: ['deadline', 'status'],
computed: {
lightInfo() {
if (['completed', 'new', 'cancelled'].includes(this.status)) return {
color: '#cccccc',
title: 'Status irrelevant für Dringlichkeit'
};
const deadlineDate = moment.unix(this.deadline);
if (!deadlineDate.isValid()) return {color: '#cccccc', title: 'Keine Deadline gesetzt'};
if (deadlineDate.isBefore(moment())) return {color: '#dc3545', title: 'Deadline überschritten'};
const daysLeft = deadlineDate.diff(moment(), 'days');
if (daysLeft <= 7) return {color: '#dc3545', title: 'Dringend: Weniger als 1 Woche'};
if (daysLeft <= 21) return {color: '#ffc107', title: 'Mittel: Weniger als 3 Wochen'};
return {color: '#28a745', title: 'Im Plan: Mehr als 3 Wochen'};
}
},
template: `<span :style="{ color: lightInfo.color, fontSize: '1.2em' }" class="mr-2" :title="lightInfo.title">&#9679;</span>`
});
Vue.component('rml-documentation-viewer-admin', {
props: ['workorderId'],
template: `
<div class="p-3 bg-light">
<div v-if="loading" class="text-center p-5"><i class="fas fa-spinner fa-spin fa-2x"></i></div>
<div v-else class="row">
<div class="col-lg-6">
<tt-file-gallery :files="docs" @selection-changed="selectedDocs = $event" selectable/>
</div>
<div class="col-lg-6">
<div class="card mb-3" v-if="selectedDocs.length > 0">
<div class="card-header"><h5><i class="fas fa-exclamation-triangle text-danger mr-2"></i>Korrektur
anfordern</h5></div>
<div class="card-body">
<p class="small text-muted">Wählen Sie die zu korrigierenden Dokumente aus der Galerie aus und geben Sie
einen Grund an.</p>
<tt-textarea v-model="correctionText" label="Grund für die Korrektur" sm row/>
<tt-button text="Korrektur anfordern" @click="requestCorrection" :loading="correctionLoading"
additional-class="btn-danger float-right"/>
</div>
</div>
<div class="card mb-3">
<div class="card-header"><h5><i class="fas fa-check-circle text-success mr-2"></i>Dokumentation
akzeptieren</h5></div>
<div class="card-body">
<p class="small text-muted">Prüfen Sie, ob alle erforderlichen Dokumente vorhanden und korrekt sind.</p>
<div v-if="loadingConfig" class="text-center"><i class="fas fa-spinner fa-spin"></i></div>
<ul v-else class="list-unstyled">
<li v-for="docType in requiredDocTypes" :key="docType.value"
class="mb-2 d-flex align-items-center small">
<i :class="isUploaded(docType.value) ? 'fas fa-check-circle text-success' : 'far fa-circle text-muted'"
class="fa-fw mr-2"></i>
<span>{{ docType.text }}</span>
</li>
</ul>
<hr>
<tt-button text="Dokumentation akzeptieren" @click="$emit('accept-documentation', workorderId)"
additional-class="btn-success float-right" icon="fas fa-check"/>
</div>
</div>
<div class="card mt-3">
<div class="card-header"><h5><i class="fas fa-history mr-2"></i>Journal</h5></div>
<div class="card-body p-0" style="max-height: 200px; overflow-y: auto;">
<ul v-if="journals.length" class="list-group list-group-flush">
<li v-for="log in journals" :key="log.id" class="list-group-item small">
<strong>{{ formatDate(log.create) }} ({{ log.createByName }}):</strong>
<div class="text-muted" style="white-space: pre-wrap;">{{ log.text }}</div>
</li>
</ul>
<div v-else class="card-body text-muted text-center">Keine Journaleinträge.</div>
</div>
<div class="card-footer">
<tt-textarea v-model="newJournalMessage" placeholder="Ihre Nachricht oder Anmerkung..." rows="2"/>
<tt-button text="Eintrag speichern" @click="addJournalEntry" :loading="addingJournalEntry"
additional-class="btn-info btn-sm w-100 mt-2" icon="fas fa-paper-plane"/>
</div>
</div>
</div>
</div>
</div>`,
data: () => ({
loading: true,
loadingConfig: true,
correctionLoading: false,
docs: [],
journals: [],
selectedDocs: [],
correctionText: '',
newJournalMessage: '',
addingJournalEntry: false,
tenantDocTypes: null
}),
computed: {
requiredDocTypes() {
if (this.tenantDocTypes) return this.tenantDocTypes;
return [{value: 'photo_hup_mounted', text: 'Foto vom montierten HÜP'}, {
value: 'photo_hup_open',
text: 'Foto von dem offenen HÜP'
}, {
value: 'photo_splice_cassette_hup',
text: 'Foto der Spleißkassette HÜP'
}, {
value: 'photo_splice_cassette_fcp',
text: 'Foto der Spleißkassette - FCP'
}, {
value: 'photo_hup_closed_stickers',
text: 'Foto vom geschlossenen HÜP mit allen Aufklebern'
}, {value: 'photo_fcp_labeled', text: 'Foto vom FCP beschriftet'}, {
value: 'photo_patch_position_osp',
text: 'Foto der Patch-Position - OSP-Seite'
}, {
value: 'photo_patch_position_anb',
text: 'Foto der Patch-Position - ANB-Seite'
}, {value: 'measurement_protocol_otdr', text: 'ODTR Messung (1310nm & 1550nm)'}];
}
},
methods: {
isUploaded(docType) {
return this.docs.some(doc => doc.documentType === docType);
},
async fetchData() {
this.loading = true;
try {
const {data} = await axios.get(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderAdmin/getDocumentation`, {params: {workorderId: this.workorderId}});
this.docs = data.docs;
this.journals = data.journals;
} catch (e) {
window.notify('error', 'Dokumentation konnte nicht geladen werden.');
} finally {
this.loading = false;
}
},
async loadTenantConfig() {
this.loadingConfig = true;
try {
const {data} = await axios.get(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderCompany/getTenantConfig`, {params: {workorderId: this.workorderId}});
if (data.success) this.tenantDocTypes = data.documentationTypes;
} catch (e) {
console.error("Konnte Mandantenkonfiguration nicht laden", e);
} finally {
this.loadingConfig = false;
}
},
async requestCorrection() {
if (!this.correctionText) return window.notify('error', 'Bitte geben Sie einen Grund für die Korrektur an.');
if (this.selectedDocs.length === 0) return window.notify('error', 'Bitte wählen Sie mindestens ein Dokument für die Korrektur aus.');
this.correctionLoading = true;
try {
const {data} = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderAdmin/requestCorrection`, {
workorderId: this.workorderId,
text: this.correctionText,
fileIds: this.selectedDocs
});
if (data.success) {
window.notify('success', data.message);
this.correctionText = '';
this.selectedDocs = [];
await this.fetchData();
this.$emit('workorder-updated');
} else window.notify('error', data.message);
} catch (e) {
window.notify('error', 'Ein Netzwerkfehler ist aufgetreten.');
}
this.correctionLoading = false;
},
async addJournalEntry() {
if (!this.newJournalMessage.trim()) return window.notify('error', 'Bitte geben Sie eine Nachricht ein.');
this.addingJournalEntry = true;
try {
const {data} = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderAdmin/addJournal`, {
workorderId: this.workorderId,
text: this.newJournalMessage
});
if (data.success) {
window.notify('success', data.message || 'Journal-Eintrag hinzugefügt.');
this.newJournalMessage = '';
this.journals = data.journals;
} else window.notify('error', data.message || 'Eintrag konnte nicht gespeichert werden.');
} catch (e) {
window.notify('error', 'Ein Netzwerkfehler ist aufgetreten.');
} finally {
this.addingJournalEntry = false;
}
},
formatDate(timestamp) {
return window.moment.unix(timestamp).format('DD.MM.YYYY HH:mm');
},
},
async mounted() {
await this.loadTenantConfig();
await this.fetchData();
}
});