Files
thetool/public/js/pages/WorkorderBase/WorkorderBase.js
2025-10-20 13:55:49 +00:00

493 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.
// 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 h-100">
<div class="card-body">
<h5 class="card-title">Tiefbau-Arbeiten</h5>
<p class="small text-muted">Schließen Sie den Tiefbau-Auftrag ab. Laden Sie Dokumente hoch, falls erforderlich.</p>
<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" class="form-text text-muted text-center mt-2">
Bitte laden Sie mindestens ein Dokument hoch, um den Auftrag abzuschließen.
</small>
</div>
</div>
</div>
<div class="col-lg-8">
<div class="card mb-3" v-if="docsRequired">
<div class="card-body">
<h5 class="card-title">Dokument hochladen</h5>
<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
v-if="docsRequired"
:files="uploadedFiles"
:edit-mode="false"
:delete-mode="true"
@delete-file="deleteDocumentation">
</tt-file-gallery>
<div v-else 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,
uploadedFiles: [], uploadData: { files: [], description: '' }, showCompleteModal: false
}),
computed: {
canComplete() {
return !this.docsRequired || this.uploadedFiles.length > 0;
}
},
methods: {
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;
if(this.docsRequired) {
const docRes = await axios.get(`${window.TT_CONFIG.BASE_PATH}/${this.isAdmin ? 'WorkorderAdmin' : 'WorkorderCompany'}/getDocumentation`, { params: { workorderId: this.workorderId } });
this.uploadedFiles = docRes.data.docs || [];
}
} 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', '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: '' };
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 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, 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 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: [],
// 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() { return this.requiredDocTypes.every(docType => this.isUploaded(docType.value)); },
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 || [];
} 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;
}
} 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'); }
},
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 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();
}
});