Files
thetool/public/js/pages/WorkorderBase/WorkorderBase.js
2026-01-27 16:09:10 +01:00

723 lines
40 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.
// WorkorderCommon.js
// A simple component to display a status light based on a deadline.
Vue.component('traffic-light', {
props: ['deadline', 'status'],
computed: {
lightInfo() {
const deadlineDate = moment.unix(this.deadline);
const daysLeft = deadlineDate.diff(moment(), 'days');
if (['completed', 'new', 'cancelled'].includes(this.status)) return { color: '#cccccc', title: 'Status irrelevant für Dringlichkeit' };
if (!deadlineDate.isValid()) return { color: '#cccccc', title: 'Keine Deadline gesetzt' };
if (deadlineDate.isBefore(moment())) return { color: '#dc3545', title: 'Deadline überschritten' };
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>`
});
// A manager for civil engineering tasks, used when a workorder requires it.
Vue.component('civil-engineering-manager', {
props: ['workorderId', 'isAdmin'],
template: `
<div class="p-3 bg-light" style="width: 100%;">
<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-4">
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title"><i class="fas fa-hard-hat text-orange mr-2"></i>Tiefbau-Arbeiten</h5>
<p class="small text-muted">Schließen Sie den Tiefbau-Auftrag ab. Laden Sie Dokumente hoch, falls erforderlich.</p>
<div v-if="tiefbauSeesNormalDocs && documentationTypes.length" class="mb-3">
<h6 class="small font-weight-bold">Checkliste</h6>
<ul class="list-unstyled mb-0">
<li v-for="docType in documentationTypes" :key="docType.value" class="mb-1 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>
</div>
<hr>
<tt-button text="Tiefbau abschließen" @click="showCompleteModal = true"
:disabled="!canComplete || completing"
:loading="completing" additional-class="btn-success w-100" icon="fas fa-check-double"
/>
<small v-if="docsRequired && !canComplete && !tiefbauSeesNormalDocs" class="form-text text-muted text-center mt-2">
Bitte laden Sie mindestens ein Tiefbau-Dokument hoch, um den Auftrag abzuschließen.
</small>
</div>
</div>
<div class="card">
<div class="card-header py-2"><h6 class="mb-0"><i class="fas fa-history mr-2"></i>Journal</h6></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 py-2">
<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="p-3 text-muted text-center small">Keine Journaleinträge.</div>
</div>
<div class="card-footer py-2">
<tt-textarea v-model="newJournalMessage" placeholder="Nachricht..." rows="2" sm no-form-group/>
<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 class="col-lg-8">
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title">Dokument hochladen</h5>
<tt-select v-if="tiefbauSeesNormalDocs && allDocTypes.length" 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"/>
</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="uploadedFiles"
:edit-mode="tiefbauSeesNormalDocs"
:delete-mode="true"
@delete-file="deleteDocumentation"
@update-file="updateDocumentation">
<template v-if="tiefbauSeesNormalDocs" v-slot:file-edit="{ file }">
<tt-select label="Dokumententyp" :options="allDocTypes" v-model="file.documentType" sm/>
</template>
</tt-file-gallery>
<div v-if="!docsRequired && !tiefbauSeesNormalDocs" class="alert alert-info">
Für diesen Auftraggeber ist keine Dokumentation für Tiefbau-Arbeiten erforderlich.
</div>
</div>
</div>
<tt-modal :show.sync="showCompleteModal" title="Tiefbau abschließen" @submit="completeTask" @close="showCompleteModal = false" :delete="false">
Möchten Sie diese Tiefbau-Arbeiten wirklich als abgeschlossen markieren?
</tt-modal>
</div>
`,
data: () => ({
loading: true, uploading: false, completing: false,
docsRequired: false, tiefbauSeesNormalDocs: false, documentationTypes: [],
uploadedFiles: [], journals: [],
uploadData: { files: [], description: '', documentType: 'civil_engineering_photo' },
showCompleteModal: false,
newJournalMessage: '', addingJournalEntry: false
}),
computed: {
canComplete() {
if (this.tiefbauSeesNormalDocs) {
// When tiefbauSeesNormalDocs is enabled, can always complete (no strict requirements)
return true;
}
// Original logic: require at least one civil engineering doc if docsRequired
return !this.docsRequired || this.uploadedFiles.length > 0;
},
allDocTypes() {
return [...this.documentationTypes, { value: 'civil_engineering_photo', text: 'Tiefbau Foto' }, { value: 'other', text: 'Sonstiges' }];
}
},
methods: {
formatDate(timestamp) { return timestamp ? window.moment.unix(timestamp).format('DD.MM.YYYY HH:mm') : ''; },
isUploaded(docType) {
return Array.isArray(this.uploadedFiles) && this.uploadedFiles.some(doc => doc.documentType === docType);
},
async fetchInitialData() {
this.loading = true;
try {
const configRes = await axios.get(`${window.TT_CONFIG.BASE_PATH}/WorkorderCompany/getTenantConfig`, { params: { workorderId: this.workorderId } });
this.docsRequired = configRes.data.civilEngineeringDocsRequired || false;
this.tiefbauSeesNormalDocs = configRes.data.tiefbauSeesNormalDocs || false;
this.documentationTypes = configRes.data.documentationTypes || [];
// Always load docs and journals
const docRes = await axios.get(`${window.TT_CONFIG.BASE_PATH}/${this.isAdmin ? 'WorkorderAdmin' : 'WorkorderCompany'}/getDocumentation`, { params: { workorderId: this.workorderId } });
this.uploadedFiles = docRes.data.docs || [];
this.journals = docRes.data.journals || [];
} catch (e) {
window.notify('error', 'Konfiguration konnte nicht geladen werden.');
console.error(e);
} finally {
this.loading = false;
}
},
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.workorderId);
formData.append('documentType', this.uploadData.documentType || 'civil_engineering_photo');
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}/WorkorderCompany/uploadDocumentation`, formData);
if (data.success) {
window.notify('success', data.message);
this.$refs.fileInput.value = '';
this.uploadData = { files: [], description: '', documentType: this.tiefbauSeesNormalDocs ? this.documentationTypes[0]?.value : 'civil_engineering_photo' };
await this.fetchInitialData();
} else window.notify('error', data.error || 'Upload fehlgeschlagen.');
} catch (e) {
window.notify('error', 'Ein Netzwerkfehler ist beim Upload aufgetreten.');
}
this.uploading = false;
},
async deleteDocumentation(file) {
try {
const {data} = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderCompany/deleteDocumentation`, {id: file.id});
if (data.success) {
window.notify('success', data.message);
await this.fetchInitialData();
} 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}/WorkorderCompany/updateDocumentation`, { id: file.id, documentType: file.documentType });
if (data.success) {
window.notify('success', data.message);
await this.fetchInitialData();
} else window.notify('error', data.message || 'Update fehlgeschlagen.');
} catch (e) {
window.notify('error', 'Netzwerkfehler beim Update.');
}
},
async addJournalEntry() {
if (!this.newJournalMessage.trim()) return;
this.addingJournalEntry = true;
try {
const {data} = await axios.post(`${window.TT_CONFIG.BASE_PATH}/${this.isAdmin ? 'WorkorderAdmin' : 'WorkorderCompany'}/addJournal`, { workorderId: this.workorderId, text: this.newJournalMessage });
if (data.success) {
this.newJournalMessage = '';
this.journals = data.journals || [];
} else window.notify('error', data.message);
} catch (e) { window.notify('error', 'Netzwerkfehler'); }
finally { this.addingJournalEntry = false; }
},
async completeTask() {
this.completing = true;
try {
const {data} = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderCompany/completeCivilEngineering`, { workorderId: this.workorderId });
if (data.success) {
window.notify('success', data.message);
this.$emit('workorder-completed');
this.showCompleteModal = false;
} else {
window.notify('error', data.message || 'Abschluss fehlgeschlagen.');
}
} catch (e) {
window.notify('error', 'Ein Netzwerkfehler ist aufgetreten.');
} finally {
this.completing = false;
}
}
},
async mounted() {
await this.fetchInitialData();
}
});
/**
* Unified component for viewing and managing Workorder Details, Documentation, and Journals.
* Adapts its UI and functionality based on the isAdmin prop.
*/
Vue.component('workorder-details-manager', {
props: {
workorderId: { type: String, required: true },
isAdmin: { type: Boolean, default: false }
},
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-5 mb-3 mb-lg-0">
<div v-if="!isAdmin" class="card mb-3">
<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 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="Auftrag zur Prüfung einreichen" @click="showCompleteModal = true"
:disabled="!canComplete || isReadOnly" :loading="completing"
additional-class="btn-success w-100" icon="fas fa-check-double"/>
<small v-if="!canComplete && !isReadOnly" class="form-text text-muted text-center mt-2">
Bitte laden Sie alle benötigten Dokumente hoch und füllen Sie alle Zusatzdaten (z.B. Kabellänge/-typ) aus, um den Auftrag abzuschließen.
</small>
<div v-if="isReadOnly" class="alert alert-secondary text-center mt-2 p-2">
Auftrag bereits abgeschlossen oder storniert. Keine Aktionen mehr möglich.
</div>
</div>
</div>
<div v-if="isAdmin" class="card mb-3">
<div class="card-body">
<h5 class="card-title">Prüfung & Freigabe</h5>
<p class="small text-muted">Prüfen Sie, ob alle erforderlichen Dokumente vorhanden und korrekt sind.</p>
<ul v-if="!loadingConfig" 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>
<div v-else class="text-center"><i class="fas fa-spinner fa-spin"></i></div>
<hr>
<tt-button text="Dokumentation akzeptieren" @click="showAcceptModal = true"
additional-class="btn-success w-100" icon="fas fa-check"/>
<tt-button v-if="workorder.status === 'documented'"
text="Status zurücksetzen (auf Zugewiesen)"
@click="showRevertModal = true"
additional-class="btn-warning w-100 mt-1" icon="fas fa-undo"/>
</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: 250px; overflow-y: auto;">
<ul v-if="journals.length" class="list-group list-group-flush">
<li v-for="log in isAdmin ? journals : journals.filter(j => !j.text.toLowerCase().includes('wurde zugewiesen.'))" :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 v-else class="card-body text-muted text-center">Keine Journaleinträge.</div>
</div>
<div class="card-footer" v-if="!isReadOnly">
<tt-textarea v-model="newJournalMessage" placeholder="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 class="col-lg-7">
<div class="card mb-3" v-if="isAdmin || !isReadOnly">
<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"/></div>
</div>
<tt-button text="Hochladen" @click="uploadFiles" :loading="uploading" additional-class="btn-primary float-right" icon="fas fa-upload"/>
</div>
</div>
<div v-if="(requireCableLength || requireCableType)" class="card mb-3">
<div class="card-body">
<h5 class="card-title">Zusatzdaten</h5>
<p class="small text-muted">Diese Daten werden für den Abschluss benötigt.</p>
<tt-input
v-if="requireCableLength"
label="Kabellänge (m)"
v-model="workorder.cableLength"
:disabled="isReadOnly"
sm row
/>
<tt-input
v-if="requireCableType"
label="Kabeltyp"
v-model="workorder.cableType"
:disabled="isReadOnly"
sm row
/>
<tt-button
text="Daten speichern"
@click="saveWorkorderData"
:loading="savingData"
:disabled="isReadOnly || savingData"
additional-class="btn-info float-right"
icon="fas fa-save"
/>
</div>
</div>
<div v-if="showTechnicalData && technicalData && (technicalData.patchposition?.equipmentName || technicalData.rimoWorkorders?.length)" class="card mb-3">
<div class="card-header bg-purple text-white d-flex justify-content-between align-items-center py-2">
<span><i class="fas fa-microchip mr-2"></i>Technische Daten</span>
<small v-if="technicalData.dropcable?.parsed_at" class="opacity-75">
<i class="fas fa-check mr-1"></i>{{ formatDate(technicalData.dropcable.parsed_at) }}
</small>
</div>
<div class="card-body py-2">
<div class="row small">
<div class="col-md-6 mb-2" v-if="technicalData.patchposition?.equipmentName">
<div class="d-flex">
<span class="text-muted mr-2"><i class="fas fa-ethernet"></i></span>
<div>
<span class="text-monospace font-weight-bold">{{ technicalData.patchposition.equipmentName }}</span>
<span v-if="technicalData.patchposition.equipmentPort" class="text-muted ml-1">Port {{ technicalData.patchposition.equipmentPort }}</span>
</div>
</div>
</div>
<div class="col-md-6 mb-2" v-if="technicalData.rimoWorkorders?.length">
<div v-for="wo in technicalData.rimoWorkorders" :key="wo.id" class="d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center">
<span class="text-muted mr-2"><i class="fas fa-file-alt"></i></span>
<span class="font-weight-bold">{{ wo.rimoName }}</span>
<span class="badge badge-light ml-2">{{ wo.rimoStatus }}</span>
</div>
<div class="btn-group btn-group-sm">
<a :href="wo.downloadUrl" target="_blank" class="btn btn-outline-secondary" title="AHA PDF"><i class="fas fa-file-pdf"></i></a>
<button @click="parseAha(wo)" class="btn btn-outline-secondary" :disabled="parsingAhaId === wo.id" :title="technicalData.dropcable?.parsed_at ? 'Aktualisieren' : 'Daten laden'">
<i :class="parsingAhaId === wo.id ? 'fas fa-spinner fa-spin' : 'fas fa-sync-alt'"></i>
</button>
</div>
</div>
</div>
</div>
<div v-if="technicalData.dropcable?.entries?.length" class="mt-2">
<table class="table table-sm table-bordered mb-0 small">
<thead class="thead-light">
<tr><th>Kabel-ID</th><th>Typ</th><th class="text-center">PLAN</th><th class="text-center">IST</th><th>Status</th></tr>
</thead>
<tbody>
<tr v-for="(dk, idx) in technicalData.dropcable.entries" :key="idx">
<td class="text-monospace">{{ dk.cable_id }}</td>
<td class="text-truncate" style="max-width:200px" :title="dk.type">{{ dk.type }}</td>
<td class="text-center">{{ dk.laenge_plan || '-' }}</td>
<td class="text-center">{{ dk.laenge_ist || '-' }}</td>
<td><span class="badge" :class="dk.status === 'Planfreigabe' ? 'badge-success' : 'badge-secondary'">{{ dk.status || '-' }}</span></td>
</tr>
</tbody>
</table>
</div>
<div v-if="technicalData.dropcable?.map_file" class="mt-2 text-center">
<a :href="technicalData.dropcable.map_file.download_url" target="_blank" class="d-block">
<img :src="technicalData.dropcable.map_file.download_url" class="img-fluid border rounded shadow-sm" style="max-height:300px;cursor:zoom-in" :alt="'Lageplan'" @error="$event.target.parentElement.style.display='none'">
</a>
</div>
</div>
</div>
<div class="card mb-3" v-if="isAdmin && selectedDocs.length > 0">
<div class="card-header bg-warning"><h5><i class="fas fa-exclamation-triangle mr-2"></i>Korrektur anfordern</h5></div>
<div class="card-body">
<p class="small text-muted">Die ausgewählten Dokumente werden als fehlerhaft markiert. Bitte geben Sie einen Grund an.</p>
<tt-textarea v-model="correctionText" label="Grund" sm row required/>
<tt-button text="Korrektur anfordern" @click="requestCorrection" :loading="correctionLoading" additional-class="btn-danger float-right"/>
</div>
</div>
<tt-file-gallery :files="docsWithStatus" :edit-mode="!isAdmin && !isReadOnly" :delete-mode="!isReadOnly" :selectable="isAdmin"
@delete-file="deleteDocumentation" @update-file="updateDocumentation" @selection-changed="selectedDocs = $event">
<template v-slot:file-edit="{ file }">
<tt-select label="Dokumententyp" :options="allDocTypes" v-model="file.documentType" sm/>
</template>
</tt-file-gallery>
<div class="card mt-3" v-if="!isAdmin && !isReadOnly">
<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>
</div>
<tt-modal v-if="interventionData" :show.sync="interventionData" title="Eingriff anfordern" @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>
<tt-modal :show.sync="showCompleteModal" title="Auftrag abschließen" @submit="completeWorkorder" :delete="false">
Möchten Sie diesen Auftrag wirklich abschließen und zur Prüfung einreichen?
</tt-modal>
<tt-modal :show.sync="showAcceptModal" title="Dokumentation akzeptieren" @submit="acceptDocumentation" :delete="false">
Soll die Dokumentation für diesen Arbeitsauftrag wirklich akzeptiert und der Auftrag abgeschlossen werden?
</tt-modal>
<tt-modal :show.sync="showRevertModal" title="Status zurücksetzen" @submit="revertDocumentedStatus" :delete="false">
Möchten Sie den Status dieses Auftrags wirklich von 'Dokumentiert' auf 'Zugewiesen' zurücksetzen? Die Firma muss den Auftrag dann erneut einreichen.
</tt-modal>
</div>`,
data: () => ({
loading: true, loadingConfig: true, workorder: null, docs: [], journals: [], tenantDocTypes: null,
newJournalMessage: '', addingJournalEntry: false,
// Company state
uploading: false, completing: false, showCompleteModal: false,
uploadData: { files: [], documentType: 'photo_hup_mounted', description: '' },
interventionData: null,
interventionTypes: [],
requireCableLength: false,
requireCableType: false,
savingData: false,
// Technical data
showTechnicalData: false,
technicalData: null,
parsingAhaId: null,
// Admin state
selectedDocs: [], correctionText: '', correctionLoading: false, showAcceptModal: false, showRevertModal: false,
}),
computed: {
isReadOnly() { return ['completed', 'cancelled'].includes(this.workorder?.status); },
requiredDocTypes() {
return this.tenantDocTypes ?? [];
},
allDocTypes() { return [...this.requiredDocTypes, {value: 'other', text: 'Sonstiges Dokument (optional)'}]; },
canComplete() {
const docsUploaded = this.requiredDocTypes.every(docType => this.isUploaded(docType.value));
if (!docsUploaded) return false;
if (this.requireCableLength && (!this.workorder.cableLength || !this.workorder.cableLength.trim())) {
return false;
}
if (this.requireCableType && (!this.workorder.cableType || !this.workorder.cableType.trim())) {
return false;
}
return true; // All checks passed
},
docsWithStatus() {
if (!this.journals?.length) return this.docs;
const correctionJournal = [...this.journals].sort((a, b) => b.create - a.create).find(j => j.statusChange?.includes('correction_requested'));
if (!correctionJournal?.fileIds) return this.docs;
try {
const incorrectFileIds = JSON.parse(correctionJournal.fileIds);
if (!Array.isArray(incorrectFileIds)) return this.docs;
return this.docs.map(doc => incorrectFileIds.includes(doc.id) ? { ...doc, class: 'border border-danger' } : doc);
} catch (e) { return this.docs; }
}
},
methods: {
formatDate(timestamp) { return timestamp ? window.moment.unix(timestamp).format('DD.MM.YYYY HH:mm') : ''; },
isUploaded(docType) {
return Array.isArray(this.docs) && this.docs.some(doc => doc.documentType === docType);
},
async fetchData() {
this.loading = true;
try {
const [workorderRes, docsJournalsRes] = await Promise.all([
axios.get(`${window.TT_CONFIG.BASE_PATH}/WorkorderCompany/getWorkorderById`, {params: {id: this.workorderId}}),
axios.get(`${window.TT_CONFIG.BASE_PATH}/${this.isAdmin ? 'WorkorderAdmin' : 'WorkorderCompany'}/getDocumentation`, {params: {workorderId: this.workorderId}})
]);
this.workorder = workorderRes.data;
// FIX: Ensure docs and journals are always arrays
this.docs = docsJournalsRes.data.docs || [];
this.journals = docsJournalsRes.data.journals || [];
// Reload tenant config to get updated technical data (AHA may have been auto-parsed)
if (this.showTechnicalData) this.loadTenantConfig();
} catch (e) {
window.notify('error', 'Details konnten nicht geladen werden.');
this.docs = []; // Ensure it's an array on error
this.journals = [];
} finally {
this.loading = false;
}
},
async loadTenantConfig() {
this.loadingConfig = true;
try {
const {data} = await axios.get(`${window.TT_CONFIG.BASE_PATH}/WorkorderCompany/getTenantConfig`, {params: {workorderId: this.workorderId}});
if (data.success) {
this.tenantDocTypes = data.documentationTypes;
this.interventionTypes = data.interventionTypes;
this.requireCableLength = data.requireCableLength || false;
this.requireCableType = data.requireCableType || false;
this.showTechnicalData = data.showTechnicalData || false;
this.technicalData = data.technicalData || null;
}
} catch (e) { console.error("Mandantenkonfiguration nicht geladen", e); }
finally { this.loadingConfig = false; }
},
async addJournalEntry() {
if (!this.newJournalMessage.trim()) return;
this.addingJournalEntry = true;
try {
const {data} = await axios.post(`${window.TT_CONFIG.BASE_PATH}/${this.isAdmin ? 'WorkorderAdmin' : 'WorkorderCompany'}/addJournal`, { workorderId: this.workorderId, text: this.newJournalMessage });
if (data.success) {
this.newJournalMessage = '';
this.journals = data.journals;
} else window.notify('error', data.message);
} catch (e) { window.notify('error', 'Netzwerkfehler'); }
finally { this.addingJournalEntry = false; }
},
// Company Methods
handleFileUpload(event) { this.uploadData.files = event.target.files; },
async uploadFiles() {
if (!this.uploadData.files?.length) return window.notify('error', 'Bitte Dateien auswählen.');
this.uploading = true;
const formData = new FormData();
formData.append('workorderId', this.workorderId);
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}/WorkorderCompany/uploadDocumentation`, formData);
if (data.success) {
window.notify('success', data.message);
this.$refs.fileInput.value = '';
this.uploadData.files = [];
await this.fetchData();
this.$emit('workorder-updated');
} else window.notify('error', data.error);
} catch (e) { window.notify('error', 'Upload-Fehler'); }
this.uploading = false;
},
async deleteDocumentation(file) {
try {
const {data} = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderCompany/deleteDocumentation`, {id: file.id});
if (data.success) {
window.notify('success', data.message);
await this.fetchData();
} else window.notify('error', data.message);
} catch (e) { window.notify('error', 'Netzwerkfehler'); }
},
async updateDocumentation(file) {
try {
const {data} = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderCompany/updateDocumentation`, { id: file.id, documentType: file.documentType });
if (data.success) {
window.notify('success', data.message);
await this.fetchData();
} else window.notify('error', data.message);
} catch (e) { window.notify('error', 'Netzwerkfehler'); }
},
async saveWorkorderData() {
this.savingData = true;
try {
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderCompany/updateWorkorderData`, {
workorderId: this.workorderId,
cableLength: this.workorder.cableLength,
cableType: this.workorder.cableType
});
if (data.success) {
window.notify('success', data.message);
if (data.journals) {
this.journals = data.journals; // Update journal with new entry
}
} else {
window.notify('error', data.message || 'Speichern fehlgeschlagen.');
}
} catch (e) {
window.notify('error', 'Ein Netzwerkfehler ist aufgetreten.');
} finally {
this.savingData = false;
}
},
openInterventionModal() {
this.interventionData = { types: [], details: { stuck: {}, stuck_fcp: {}, stuck_hup: {}, other: {} } };
},
async requestIntervention() {
const { types, details } = this.interventionData;
if (types.length === 0) return window.notify('error', 'Bitte Problem auswählen.');
let journalParts = [];
for (const type of types.sort()) {
const problemText = this.interventionTypes.find(o => o.value === type)?.text || type;
if (['stuck', 'stuck_fcp', 'stuck_hup'].includes(type)) {
if (!details[type]?.distance > 0) return window.notify('error', `Bitte DisWtanz für "${problemText}" eingeben.`);
journalParts.push(problemText.replace('X', details[type].distance));
} else if (type === 'other') {
if (!details.other?.reason?.trim()) return window.notify('error', `Bitte Grund für "Sonstiges" angeben.`);
journalParts.push(`Sonstiges: ${details.other.reason.trim()}`);
} else journalParts.push(problemText);
}
try {
const {data} = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderCompany/requestIntervention`, { workorderId: this.workorderId, journalText: journalParts.join('\\n') });
if (data.success) {
window.notify('success', data.message);
this.interventionData = null;
this.$emit('workorder-completed');
} else window.notify('error', data.message);
} catch (e) { window.notify('error', 'Netzwerkfehler'); }
},
async completeWorkorder() {
this.completing = true;
try {
const {data} = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderCompany/completeWorkorder`, {workorderId: this.workorderId});
if (data.success) {
window.notify('success', data.message);
this.$emit('workorder-completed');
this.showCompleteModal = false;
} else window.notify('error', data.message);
} catch (e) { window.notify('error', 'Netzwerkfehler'); }
this.completing = false;
},
// Admin Methods
async requestCorrection() {
if (!this.correctionText) return window.notify('error', 'Bitte geben Sie einen Grund an.');
if (this.selectedDocs.length === 0) return window.notify('error', 'Bitte Dokumente für die Korrektur auswählen.');
this.correctionLoading = true;
try {
const {data} = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderAdmin/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-completed');
} else window.notify('error', data.message);
} catch (e) { window.notify('error', 'Netzwerkfehler'); }
this.correctionLoading = false;
},
acceptDocumentation() {
this.$emit('accept-documentation', this.workorderId);
this.showAcceptModal = false;
},
getInterventionLabel(type) { return this.interventionTypes.find(t => t.value === type)?.text || type; },
async parseAha(wo) {
this.parsingAhaId = wo.id;
try {
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RimoWorkorder/parseAha`, { id: wo.id });
if (data.success) {
window.notify('success', `AHA-Daten geladen: ${data.dropkabel_count} Dropkabel${data.has_map ? ', Lageplan vorhanden' : ''}`);
// Reload technical data to show the parsed data
await this.loadTenantConfig();
} else {
window.notify('error', data.message || 'Fehler beim Laden der AHA-Daten');
}
} catch (e) {
window.notify('error', 'Netzwerkfehler beim Laden der AHA-Daten');
console.error(e);
} finally {
this.parsingAhaId = null;
}
},
async revertDocumentedStatus() {
// Optional: Add loading state if needed
try {
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderAdmin/revertDocumentedStatus`, {
workorderId: this.workorderId
});
if (data.success) {
window.notify('success', data.message);
this.showRevertModal = false;
await this.fetchData(); // Refresh data to show new status
this.$emit('workorder-updated'); // Or a more specific event if needed
} else {
window.notify('error', data.message || 'Status konnte nicht zurückgesetzt werden.');
}
} catch (e) {
window.notify('error', 'Netzwerkfehler beim Zurücksetzen des Status.');
}
},
},
async mounted() {
await this.loadTenantConfig();
await this.fetchData();
}
});