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

568 lines
28 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.
// RMLWorkorderCompany.js
Vue.component('r-m-l-workorder-company', {
template: `
<div>
<tt-card>
<tt-table-crud
ref="table"
:crud-config="crudConfig"
>
<template v-slot:preorderinfo="{ row }">
<div v-html="row.preorderInfo" class="small"></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>
</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
v-if="!['completed', 'cancelled', 'documented'].includes(row.status)"
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 }">
{{ formatDate(row.deadlineDate) }}
</template>
<template v-slot:appointmentdate="{ row }">
<div
v-if="!row.appointmentDate && ['assigned', 'correction_requested', 'problem_solved'].includes(row.status)">
<tt-date-picker
placeholder="Termin festlegen..." :date-range="false"
@input="setAppointment(row, $event)" sm no-form-group
:additional-props="{ timePicker: true, timePicker24Hour: true, locale: { format: 'DD.MM.YYYY HH:mm' }, drops: 'up' }"
/>
</div>
<div v-else-if="row.appointmentDate">
<span>{{ formatDate(row.appointmentDate, true) }}</span>
<tt-button
v-if="!['completed', 'cancelled', 'documented'].includes(row.status)"
icon="fas fa-edit" @click="openRescheduleModal(row)"
additional-class="btn-link btn-sm p-0 ml-2" title="Termin ändern"
/>
</div>
<span v-else></span>
</template>
<template v-slot:expandedRow="{ row }">
<documentation-manager
:workorder-id="row.id"
@workorder-completed="$refs.table.$refs.table.refreshTable()"
/>
</template>
</tt-table-crud>
</tt-card>
<tt-modal v-if="rescheduleData" :show="true" :delete="false" title="Termin verschieben"
@update:show="closeRescheduleModal" @submit="rescheduleAppointment">
<p><strong>Auftrag:</strong> #{{ rescheduleData.workorder.id }}</p>
<tt-date-picker
label="Neuer Termin" :date-range="false" v-model="rescheduleData.newDate"
sm row
:additional-props="{ timePicker: true, timePicker24Hour: true, locale: { format: 'DD.MM.YYYY HH:mm' }, singleDatePicker: true }"
/>
<tt-textarea label="Grund" v-model="rescheduleData.reason" sm row required/>
</tt-modal>
</div>
`,
data() {
return {
rescheduleData: null, editingAdditionalInfoId: null, tempAdditionalInfo: '',
crudConfig: {
...window.TT_CONFIG.CRUD_CONFIG, 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: {
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 setAppointment(workorder, date) {
if (!date) return;
const hour = moment.unix(date).hour();
if (hour >= 23 || hour < 1) {
this.$refs.table.$refs.table.refreshTable();
return window.notify('error', 'Bitte Uhrzeit angeben!');
}
try {
const {data} = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderCompany/scheduleAppointment`, {
workorderId: workorder.id,
appointmentDate: date
});
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', e.response?.data?.message || 'Ein Netzwerkfehler ist aufgetreten.');
}
},
openRescheduleModal(row) {
this.rescheduleData = {workorder: row, newDate: row.appointmentDate, reason: ''};
},
closeRescheduleModal() {
this.rescheduleData = null;
},
async rescheduleAppointment() {
const {workorder, newDate, reason} = this.rescheduleData;
if (!newDate || !reason) return window.notify('error', 'Bitte geben Sie ein neues Datum und einen Grund an.');
if (moment.unix(newDate).hour() >= 23 || moment.unix(newDate).hour() < 1) return window.notify('error', 'Bitte Uhrzeit angeben!');
try {
const {data} = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderCompany/rescheduleAppointment`, {
workorderId: workorder.id,
appointmentDate: newDate,
reason: reason
});
if (data.success) {
window.notify('success', data.message);
this.$refs.table.$refs.table.refreshTable();
this.closeRescheduleModal();
} else window.notify('error', data.message || 'Ein Fehler ist aufgetreten.');
} catch (e) {
window.notify('error', e.response?.data?.message || '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}/RMLWorkorderCompany/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();
}
},
}
});
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('documentation-manager', {
props: ['workorderId'],
template: `
<div class="p-3 bg-light" style="width: 100%;">
<div v-if="loadingWorkorder || loadingConfig" 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-4 mb-3 mb-lg-0">
<div>
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">Benötigte Dokumente</h5>
<ul class="list-unstyled">
<li v-for="docType in requiredDocTypes" :key="docType.value" class="mb-2 d-flex align-items-center">
<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="Auftrag abschließen" @click="completeWorkorder"
:disabled="!canComplete || ['documented', 'completed', 'cancelled'].includes(workorder.status)"
:loading="completing" additional-class="btn-success w-100" icon="fas fa-check-double"
/>
<small v-if="!canComplete && !['documented', 'completed', 'cancelled'].includes(workorder.status)"
class="form-text text-muted text-center mt-2">
Bitte laden Sie alle benötigten Dokumente hoch, um den Auftrag abzuschließen.
</small>
<div v-if="['documented', 'completed', 'cancelled'].includes(workorder.status)"
class="alert alert-secondary text-center mt-2 p-2">
Auftrag zur Prüfung eingereicht oder storniert.
</div>
</div>
</div>
<div class="card mt-3"
v-if="['assigned', 'scheduled', 'correction_requested', 'problem_solved'].includes(workorder.status)">
<div class="card-header bg-danger text-white"><h5><i class="fas fa-hard-hat mr-2"></i>Eingriff benötigt
</h5></div>
<div class="card-body">
<p class="small text-muted">Falls ein Problem auftritt, das ein Eingreifen erfordert, melden Sie es
hier.</p>
<tt-button text="Problem melden" @click="openInterventionModal" additional-class="btn-danger w-100"
icon="fas fa-exclamation-triangle"/>
</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 class="list-group list-group-flush">
<li v-if="!journals.length" class="list-group-item text-center text-muted">Keine Einträge
vorhanden.
</li>
<li v-for="log in journals" :key="log.id" class="list-group-item small"
:class="{'list-group-item-danger': log.statusChange && (log.statusChange.includes('correction_requested') || log.statusChange.includes('intervention_required'))}">
<strong>{{ formatDate(log.create) }} ({{ log.createByName }}):</strong>
<div class="text-muted" style="white-space: pre-wrap;">{{ log.text }}</div>
</li>
</ul>
</div>
<div class="card-footer" v-if="!['completed', 'documented', 'cancelled'].includes(workorder.status)">
<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 class="col-lg-8">
<div class="card mb-3" v-if="!['documented', 'completed', 'cancelled'].includes(workorder.status)">
<div class="card-body">
<h5 class="card-title">Neues Dokument hochladen</h5>
<tt-select label="Dokumententyp" :options="allDocTypes" v-model="uploadData.documentType" sm row/>
<tt-input label="Beschreibung" v-model="uploadData.description" sm row placeholder="Optional"/>
<div class="form-group row">
<label class="col-form-label col-sm-4 col-form-label-sm">Dateien</label>
<div class="col-sm-8 p-0">
<input type="file" class="form-control-file form-control-sm p-0" @change="handleFileUpload"
ref="fileInput" multiple accept="image/*,.pdf,.doc,.docx"/>
</div>
</div>
<tt-button text="Hochladen" @click="uploadFiles" :loading="uploading"
additional-class="btn-primary float-right" icon="fas fa-upload"/>
</div>
</div>
<tt-file-gallery
:files="filesWithStatus"
:edit-mode="!['completed', 'documented', 'cancelled'].includes(workorder.status)"
:delete-mode="!['completed', 'documented', 'cancelled'].includes(workorder.status)"
@delete-file="deleteDocumentation" @update-file="updateDocumentation">
<template v-slot:file-edit="{ file }">
<tt-select label="Dokumententyp" :options="allDocTypes" v-model="file.documentType" sm/>
</template>
</tt-file-gallery>
</div>
</div>
<tt-modal v-if="interventionData" :show="true" :delete="false" title="Eingriff anfordern"
@update:show="interventionData = null" @submit="requestIntervention">
<tt-select label="Art des Problems" :options="interventionTypes" v-model="interventionData.types" sm row
multiple/>
<div v-for="type in interventionData.types" :key="type">
<tt-input v-if="['stuck', 'stuck_fcp', 'stuck_hup'].includes(type)"
:label="'Distanz (m) für \\'' + getInterventionLabel(type) + '\\''" type="number"
v-model="interventionData.details[type].distance" sm row required/>
<tt-textarea v-if="type === 'other'" label="Grund für 'Sonstiges'"
v-model="interventionData.details.other.reason" sm row required/>
</div>
</tt-modal>
</div>`,
data: () => ({
loadingWorkorder: true,
loadingConfig: true,
tenantDocTypes: null,
workorder: null,
uploading: false,
completing: false,
uploadedFiles: [],
journals: [],
newJournalMessage: '',
addingJournalEntry: false,
interventionData: null,
interventionTypes: [{value: 'stuck', text: 'Ab X Laufmeter stecken geblieben'}, {
value: 'stuck_fcp',
text: 'Vom FCP nach HÜP nach X Laufmetern stecken geblieben'
}, {value: 'stuck_hup', text: 'Vom HÜP nach FCP nach X Laufmetern stecken geblieben'}, {
value: 'no_air',
text: 'Keine Luftverbindung'
}, {value: 'other', text: 'Sonstiges'}],
uploadData: {files: [], documentType: 'photo_hup_mounted', description: ''}
}),
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)'}];
},
allDocTypes() {
return [...this.requiredDocTypes, {value: 'other', text: 'Sonstiges Dokument (optional)'}];
},
canComplete() {
return this.requiredDocTypes.every(docType => this.isUploaded(docType.value));
},
filesWithStatus() {
if (!this.journals?.length) return this.uploadedFiles;
const correctionJournal = [...this.journals].sort((a, b) => b.create - a.create).find(j => j.statusChange?.includes('correction_requested'));
if (!correctionJournal?.fileIds) return this.uploadedFiles;
try {
const incorrectFileIds = JSON.parse(correctionJournal.fileIds);
if (!Array.isArray(incorrectFileIds)) return this.uploadedFiles;
return this.uploadedFiles.map(file => incorrectFileIds.includes(file.id) ? {
...file,
class: 'border border-danger'
} : file);
} catch (e) {
return this.uploadedFiles;
}
}
},
methods: {
formatDate(timestamp) {
return timestamp ? window.moment.unix(timestamp).format('DD.MM.YYYY HH:mm') : '';
},
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 loadWorkorder() {
this.loadingWorkorder = true;
try {
const {data} = await axios.get(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderCompany/getWorkorderById`, {params: {id: this.workorderId}});
this.workorder = data;
} catch (e) {
window.notify('error', 'Arbeitsauftragsdetails konnten nicht geladen werden.');
}
this.loadingWorkorder = false;
},
isUploaded(docType) {
return this.uploadedFiles.some(file => file.documentType === docType);
},
async fetchDocs() {
try {
const {data} = await axios.get(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderCompany/getDocumentation`, {params: {workorderId: this.workorderId}});
this.uploadedFiles = data.docs;
this.journals = data.journals;
} catch (e) {
window.notify('error', 'Dokumente konnten nicht geladen werden.');
}
},
handleFileUpload(event) {
this.uploadData.files = event.target.files;
},
async uploadFiles() {
if (!this.uploadData.files?.length) return window.notify('error', 'Bitte eine oder mehrere Dateien auswählen.');
this.uploading = true;
const formData = new FormData();
formData.append('workorderId', this.workorder.id);
formData.append('documentType', this.uploadData.documentType);
formData.append('description', this.uploadData.description);
for (const file of this.uploadData.files) formData.append('files[]', file);
try {
const {data} = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderCompany/uploadDocumentation`, formData);
if (data.success) {
window.notify('success', data.message);
this.$refs.fileInput.value = '';
this.uploadData.files = [];
this.uploadData.description = '';
this.uploadedFiles = data.docs;
this.workorder = data.workorder;
} else window.notify('error', data.error || 'Upload fehlgeschlagen.');
} catch (e) {
window.notify('error', 'Ein Netzwerkfehler ist beim Upload aufgetreten.');
}
this.uploading = false;
},
async completeWorkorder() {
if (!confirm('Möchten Sie diesen Auftrag wirklich abschließen und zur Prüfung einreichen?')) return;
this.completing = true;
try {
const {data} = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderCompany/completeWorkorder`, {workorderId: this.workorder.id});
if (data.success) {
window.notify('success', data.message);
this.$emit('workorder-completed');
} else window.notify('error', data.message || 'Ein Fehler ist aufgetreten.');
} catch (e) {
window.notify('error', 'Ein Netzwerkfehler ist aufgetreten.');
}
this.completing = false;
},
async deleteDocumentation(file) {
try {
const {data} = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderCompany/deleteDocumentation`, {id: file.id});
if (data.success) {
window.notify('success', data.message);
this.uploadedFiles = data.docs;
} else window.notify('error', data.message || 'Löschen fehlgeschlagen.');
} catch (e) {
window.notify('error', 'Netzwerkfehler beim Löschen.');
}
},
async updateDocumentation(file) {
try {
const {data} = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderCompany/updateDocumentation`, {
id: file.id,
documentType: file.documentType
});
if (data.success) {
window.notify('success', data.message);
this.uploadedFiles = data.docs;
} else window.notify('error', data.message || 'Update fehlgeschlagen.');
} catch (e) {
window.notify('error', 'Netzwerkfehler beim Aktualisieren.');
}
},
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}/RMLWorkorderCompany/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;
}
},
getInterventionLabel(type) {
return this.interventionTypes.find(t => t.value === type)?.text || type;
},
openInterventionModal() {
this.interventionData = {
types: [],
details: {
stuck: {distance: ''},
stuck_fcp: {distance: ''},
stuck_hup: {distance: ''},
other: {reason: ''}
}
};
},
async requestIntervention() {
const {types, details} = this.interventionData;
if (types.length === 0) return window.notify('error', 'Bitte wählen Sie mindestens ein Problem aus.');
let journalParts = [];
types.sort();
for (const type of types) {
let text = '';
const problemText = this.interventionTypes.find(o => o.value === type)?.text || 'Unbekanntes Problem';
if (['stuck', 'stuck_fcp', 'stuck_hup'].includes(type)) {
const distance = details[type]?.distance;
if (!distance || isNaN(distance) || distance <= 0) return window.notify('error', `Bitte eine gültige Distanz für "${problemText}" eingeben.`);
text = problemText.replace('X', distance);
} else if (type === 'no_air') text = problemText;
else if (type === 'other') {
const reason = details.other?.reason;
if (!reason?.trim()) return window.notify('error', 'Bitte geben Sie einen Grund für "Sonstiges" an.');
text = `Sonstiges: ${reason.trim()}`;
}
if (text) journalParts.push(text);
}
const journalText = journalParts.join('\\n');
if (!journalText) return window.notify('error', 'Keine gültigen Problemdetails zum Senden gefunden.');
try {
const {data} = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderCompany/requestIntervention`, {
workorderId: this.workorderId,
journalText
});
if (data.success) {
window.notify('success', data.message);
this.interventionData = null;
this.$emit('workorder-completed');
} else window.notify('error', data.message || 'Ein Fehler ist aufgetreten.');
} catch (e) {
window.notify('error', 'Ein Netzwerkfehler ist aufgetreten.');
}
}
},
async mounted() {
await this.loadWorkorder();
await this.loadTenantConfig();
await this.fetchDocs();
}
});