Files
thetool/public/js/pages/WorkorderMphBase/WorkorderMphBase.js

587 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.
// WorkorderMphBase.js - Shared components for WorkorderMph module
// Traffic light component (reused from WorkorderBase)
Vue.component('traffic-light-mph', {
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>`
});
// Wohneinheit Status Manager Component
Vue.component('wohneinheit-status-manager', {
props: {
workorderMphId: { type: Number, required: true },
isAdmin: { type: Boolean, default: false }
},
template: `
<div class="card wohneinheit-manager">
<div class="card-header bg-primary text-white d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="fas fa-building mr-2"></i>Wohneinheiten Status</h5>
<a v-if="hausnummerId" :href="'/AddressDB/view/?id=' + hausnummerId" target="_blank" class="text-white small">
<i class="fas fa-external-link-alt mr-1"></i>AddressDB #{{ hausnummerId }}
</a>
</div>
<div v-if="loading" class="text-center p-5"><i class="fas fa-spinner fa-spin fa-2x"></i></div>
<div v-else class="card-body p-0">
<div v-if="wohneinheiten.length === 0" class="alert alert-info m-3">
Keine Wohneinheiten gefunden.
</div>
<div v-else class="we-table">
<div v-for="we in wohneinheiten" :key="we.wohneinheitId" class="we-row" :class="{ 'locked-row': isLocked(we) }">
<div class="we-cell we-zusatz">
<div class="form-group mb-1">
<small class="text-muted">Zusatz</small>
<input type="text" class="form-control form-control-sm" v-model="we.zusatz"
@input="debouncedSave(we)" :disabled="isLocked(we)">
</div>
<div class="text-muted small">
<i class="fas fa-id-card mr-1"></i>OAID: {{ we.oaid }}
</div>
<div v-if="we.contact" class="contact-info text-muted">
<i class="fas fa-user mr-1"></i>{{ we.contact }}
</div>
<div v-else-if="we.preorderContact" class="contact-info text-info" title="Daten aus Vorbestellung">
<i class="fas fa-user-check mr-1"></i>{{ we.preorderContact }}
</div>
<div v-else class="text-muted small fst-italic">
<i class="fas fa-user-slash mr-1"></i>Keine Kontaktinfo
</div>
<div v-if="we.preorderUcode" class="mt-1">
<a :href="'/Preorder/Index?filter[ucode]=' + we.preorderUcode" target="_blank" class="badge badge-info">
<i class="fas fa-shopping-cart mr-1"></i>Vorbestellung
</a>
</div>
</div>
<div class="we-cell we-tuer">
<div class="form-group mb-0">
<small class="text-muted">Tür</small>
<input type="text" class="form-control form-control-sm" v-model="we.tuer"
@input="debouncedSave(we)" :disabled="isLocked(we)">
</div>
</div>
<div class="we-cell we-status">
<small class="text-muted d-block mb-1">Status</small>
<div v-if="isLocked(we)" class="locked-status">
<i class="fas fa-lock mr-1"></i> {{ getStatusText(we.status) }}
</div>
<tt-select v-else v-model="we.status" :options="filteredStatusOptions" sm no-form-group
@input="saveWohneinheit(we)"/>
<div v-if="we.saving" class="text-muted small mt-1"><i class="fas fa-sync fa-spin mr-1"></i>Speichert...</div>
</div>
<div class="we-cell we-splice text-center">
<label class="custom-checkbox-item mb-0" title="Spleiß im HÜP/HAK erledigt">
<input type="checkbox" v-model="we.spliceCompleted" @change="saveWohneinheit(we)" :disabled="isLocked(we)">
<span class="checkmark small-checkmark"></span>
<span class="d-block small mt-1">Spleiß</span>
</label>
</div>
</div>
</div>
</div>
</div>
`,
data: () => ({
loading: true,
wohneinheiten: [],
statusOptions: [],
hausnummerId: null,
debounceTimers: {}
}),
computed: {
filteredStatusOptions() {
// Filter out status with code 300
return this.statusOptions.filter(opt => opt.code !== 300);
}
},
methods: {
async fetchWohneinheiten() {
this.loading = true;
try {
const basePath = this.isAdmin ? '/WorkorderMphAdmin' : '/WorkorderMphCompany';
const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}${basePath}/getWohneinheiten`, {
params: { workorderMphId: this.workorderMphId }
});
this.wohneinheiten = data.wohneinheiten.map(we => ({
...we,
spliceCompleted: !!we.spliceCompleted,
saving: false
}));
this.statusOptions = data.statusOptions || [];
this.hausnummerId = data.hausnummerId;
} catch (e) {
window.notify('error', 'Wohneinheiten konnten nicht geladen werden.');
console.error(e);
} finally {
this.loading = false;
}
},
isLocked(we) {
const status = this.statusOptions.find(s => s.value === we.status);
return status && status.code === 300;
},
debouncedSave(we) {
we.saving = true;
if (this.debounceTimers[we.wohneinheitId]) {
clearTimeout(this.debounceTimers[we.wohneinheitId]);
}
this.debounceTimers[we.wohneinheitId] = setTimeout(() => {
this.saveWohneinheit(we);
}, 1000);
},
async saveWohneinheit(we) {
we.saving = true;
try {
const basePath = this.isAdmin ? '/WorkorderMphAdmin' : '/WorkorderMphCompany';
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}${basePath}/updateWohneinheit`, {
workorderMphId: this.workorderMphId,
wohneinheitId: we.wohneinheitId,
status: we.status,
spliceCompleted: we.spliceCompleted ? 1 : 0,
tuer: we.tuer,
zusatz: we.zusatz
});
if (data.success) {
// Silent success or small indicator
this.$emit('wohneinheit-updated');
} else {
window.notify('error', data.message || 'Speichern fehlgeschlagen.');
}
} catch (e) {
window.notify('error', 'Ein Netzwerkfehler ist aufgetreten.');
} finally {
we.saving = false;
}
},
getStatusText(statusValue) {
const option = this.statusOptions.find(opt => opt.value === statusValue);
return option ? option.text : '';
}
},
async mounted() {
await this.fetchWohneinheiten();
}
});
// Checkbox Documentation Component
Vue.component('checkbox-documentation', {
props: {
workorderMphId: { type: Number, required: true },
isAdmin: { type: Boolean, default: false }
},
template: `
<div class="checkbox-docs card mb-3">
<div class="card-body p-3">
<h5 class="card-title mb-3"><i class="fas fa-clipboard-check mr-2"></i>Dokumentation Checkboxen</h5>
<div v-if="loading" class="text-center p-3"><i class="fas fa-spinner fa-spin"></i></div>
<div v-else>
<div class="custom-checkboxes-grid">
<label class="custom-checkbox-item">
<input type="checkbox" v-model="checkboxes.easement" @change="saveCheckboxes">
<span class="checkmark"></span>
<span class="checkbox-label">Leitungsrecht</span>
</label>
<label class="custom-checkbox-item">
<input type="checkbox" v-model="checkboxes.btb" @change="saveCheckboxes">
<span class="checkmark"></span>
<span class="checkbox-label">Betriebstechnische Begehung</span>
</label>
<label class="custom-checkbox-item">
<input type="checkbox" v-model="checkboxes.fttxLocationSupplied" @change="saveCheckboxes">
<span class="checkmark"></span>
<span class="checkbox-label">FTTx Location mit Leerrohr versorgt</span>
</label>
<label class="custom-checkbox-item">
<input type="checkbox" v-model="checkboxes.conduitToHuepLaid" @change="saveCheckboxes">
<span class="checkmark"></span>
<span class="checkbox-label">Leerrohr bis HÜP/HAK verlegt</span>
</label>
<label class="custom-checkbox-item">
<input type="checkbox" v-model="checkboxes.huepMounted" @change="saveCheckboxes">
<span class="checkmark"></span>
<span class="checkbox-label">HÜP/HAK montiert</span>
</label>
<label class="custom-checkbox-item">
<input type="checkbox" v-model="checkboxes.dropCableAvailable" @change="saveCheckboxes">
<span class="checkmark"></span>
<span class="checkbox-label">Dropkabel vorhanden</span>
</label>
</div>
<div v-if="saving" class="mt-2 text-right text-muted small">
<i class="fas fa-sync fa-spin mr-1"></i>Speichert...
</div>
</div>
</div>
</div>
`,
data: () => ({
loading: true,
saving: false,
checkboxes: {
easement: false,
btb: false,
fttxLocationSupplied: false,
conduitToHuepLaid: false,
huepMounted: false,
dropCableAvailable: false
}
}),
methods: {
async fetchCheckboxes() {
this.loading = true;
try {
const basePath = this.isAdmin ? '/WorkorderMphAdmin' : '/WorkorderMphCompany';
const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}${basePath}/getWorkorderById`, {
params: { id: this.workorderMphId }
});
this.checkboxes = {
easement: !!data.easement,
btb: !!data.btb,
fttxLocationSupplied: !!data.fttxLocationSupplied,
conduitToHuepLaid: !!data.conduitToHuepLaid,
huepMounted: !!data.huepMounted,
dropCableAvailable: !!data.dropCableAvailable
};
} catch (e) {
window.notify('error', 'Checkboxen konnten nicht geladen werden.');
console.error(e);
} finally {
this.loading = false;
}
},
async saveCheckboxes() {
this.saving = true;
try {
const basePath = this.isAdmin ? '/WorkorderMphAdmin' : '/WorkorderMphCompany';
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}${basePath}/updateCheckboxes`, {
workorderMphId: this.workorderMphId,
...this.checkboxes
});
if (data.success) {
// Silent success
this.$emit('checkboxes-updated');
} else {
window.notify('error', data.message || 'Speichern fehlgeschlagen.');
}
} catch (e) {
window.notify('error', 'Ein Netzwerkfehler ist aufgetreten.');
} finally {
this.saving = false;
}
}
},
async mounted() {
await this.fetchCheckboxes();
}
});
// WorkorderMph Details Manager
Vue.component('workorder-mph-details-manager', {
props: {
workorderMphId: { type: String, required: true },
isAdmin: { type: Boolean, default: false }
},
data: () => ({
loading: true,
docs: [],
journals: [],
newJournalMessage: '',
addingJournalEntry: false,
uploading: false,
completing: false,
showCompleteModal: false,
showAcceptModal: false,
uploadData: { files: [], documentType: '', description: '' },
wohneinheitenWithNotes: true,
requiredDocs: [
{ key: 'huep_photo', label: 'HÜP/HAK Foto', icon: 'fas fa-camera', example: 'Foto der installierten HÜP/HAK' },
{ key: 'bep_md_photo', label: 'BEP MD Foto', icon: 'fas fa-camera', example: 'Foto der BEP (MD) Installation' },
{ key: 'ont_photo', label: 'ONT Foto', icon: 'fas fa-camera', example: 'Foto der ONT Installation' },
{ key: 'cable_routing', label: 'Kabelverlegung', icon: 'fas fa-route', example: 'Fotos der Kabelverlegung im Treppenhaus' },
{ key: 'fttx_location', label: 'FTTx Location', icon: 'fas fa-map-marker-alt', example: 'Foto/Dokument der FTTx Location' },
{ key: 'signature', label: 'Unterschrift', icon: 'fas fa-signature', example: 'Unterschriebenes Übergabeprotokoll' },
{ key: 'other', label: 'Sonstige Dokumentation', icon: 'fas fa-file', example: 'Weitere relevante Dokumente' }
]
}),
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">Auftrag abschließen</h5>
<p class="small text-muted">Dokumentieren Sie alle Wohneinheiten und laden Sie die erforderlichen Dokumente hoch.</p>
<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 fügen Sie für jede Wohneinheit eine Notiz hinzu und laden Sie Dokumente hoch.
</small>
<div v-if="isReadOnly" class="alert alert-secondary text-center mt-2 p-2">
Auftrag bereits abgeschlossen oder storniert.
</div>
</div>
</div>
<div v-if="isAdmin" class="card mb-3">
<div class="card-body p-3">
<h5 class="card-title mb-3">Prüfung & Freigabe</h5>
<p class="small text-muted mb-3">Prüfen Sie die hochgeladenen Dokumente:</p>
<div class="required-docs-checklist mb-3">
<div v-for="doc in requiredDocs" :key="doc.key" class="doc-check-item">
<i :class="[doc.icon, hasDocType(doc.key) ? 'text-success' : 'text-muted']"></i>
<span :class="{ 'text-success': hasDocType(doc.key) }">{{ doc.label }}</span>
<i v-if="hasDocType(doc.key)" class="fas fa-check-circle text-success ml-auto"></i>
<i v-else class="fas fa-circle text-muted ml-auto" style="font-size: 0.8em;"></i>
</div>
</div>
<small class="text-muted d-block mb-3">
<i class="fas fa-info-circle"></i> Stellen Sie sicher, dass alle relevanten Dokumente vorhanden sind.
</small>
<tt-button text="Dokumentation akzeptieren" @click="showAcceptModal = true"
additional-class="btn-success w-100" 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: 250px; 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" 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="!isReadOnly">
<div class="card-body p-3">
<h5 class="card-title mb-3">Neues Dokument hochladen</h5>
<tt-select label="Dokumententyp*" :options="docTypeOptions" v-model="uploadData.documentType" sm row required/>
<div v-if="uploadData.documentType" class="row mb-2">
<div class="col-sm-8 offset-sm-4">
<small class="form-text text-muted mt-0">
<i class="fas fa-info-circle"></i> {{ getDocExample(uploadData.documentType) }}
</small>
</div>
</div>
<tt-input label="Beschreibung" v-model="uploadData.description" sm row placeholder="Optional: Zusätzliche Beschreibung"/>
<div class="form-group row mb-3">
<label class="col-form-label col-sm-4 col-form-label-sm">Dateien*</label>
<div class="col-sm-8">
<input type="file" class="form-control-file form-control-sm p-0"
@change="handleFileUpload" ref="fileInput" multiple accept="image/*,.pdf"/>
<small class="form-text text-muted">Erlaubt: Bilder (JPG, PNG) und PDF</small>
</div>
</div>
<div class="text-right">
<tt-button text="Hochladen" @click="uploadFiles" :loading="uploading"
:disabled="!uploadData.documentType || !uploadData.files.length"
additional-class="btn-primary btn-sm" icon="fas fa-upload"/>
</div>
</div>
</div>
<tt-file-gallery :files="docs" :edit-mode="false" :delete-mode="!isReadOnly && !isAdmin"
@delete-file="deleteDocumentation">
</tt-file-gallery>
</div>
</div>
<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>
</div>
`,
computed: {
isReadOnly() {
return ['completed', 'cancelled'].includes(this.workorder?.status);
},
canComplete() {
return this.wohneinheitenWithNotes && this.docs.length > 0;
},
docTypeOptions() {
return [
{ value: '', text: '-- Bitte wählen --' },
...this.requiredDocs.map(doc => ({ value: doc.key, text: doc.label }))
];
}
},
methods: {
formatDate(timestamp) {
return timestamp ? window.moment.unix(timestamp).format('DD.MM.YYYY HH:mm') : '';
},
hasDocType(docType) {
return this.docs.some(doc => doc.documentType === docType);
},
getDocExample(docType) {
const doc = this.requiredDocs.find(d => d.key === docType);
return doc ? doc.example : '';
},
async fetchData() {
this.loading = true;
try {
const basePath = this.isAdmin ? '/WorkorderMphAdmin' : '/WorkorderMphCompany';
const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}${basePath}/getDocumentation`, {
params: { workorderMphId: this.workorderMphId }
});
this.docs = data.docs || [];
this.journals = data.journals || [];
} catch (e) {
window.notify('error', 'Details konnten nicht geladen werden.');
this.docs = [];
this.journals = [];
} finally {
this.loading = false;
}
},
async addJournalEntry() {
if (!this.newJournalMessage.trim()) return window.notify('error', 'Bitte eine Nachricht eingeben.');
this.addingJournalEntry = true;
try {
const basePath = this.isAdmin ? '/WorkorderMphAdmin' : '/WorkorderMphCompany';
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}${basePath}/addJournal`, {
workorderMphId: this.workorderMphId,
text: this.newJournalMessage
});
if (data.success) {
window.notify('success', data.message);
this.journals = data.journals || [];
this.newJournalMessage = '';
} else {
window.notify('error', data.message || 'Eintrag fehlgeschlagen.');
}
} catch (e) {
window.notify('error', 'Ein Netzwerkfehler ist aufgetreten.');
} finally {
this.addingJournalEntry = 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('workorderMphId', this.workorderMphId);
formData.append('documentType', this.uploadData.documentType);
formData.append('description', this.uploadData.description);
for (const file of this.uploadData.files) {
formData.append('file', file);
}
try {
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphCompany/uploadDocumentation`, formData);
if (data.success) {
window.notify('success', data.message);
this.$refs.fileInput.value = '';
this.uploadData = { files: [], documentType: '', description: '' };
await this.fetchData();
} else {
window.notify('error', data.error || 'Upload fehlgeschlagen.');
}
} catch (e) {
window.notify('error', 'Ein Netzwerkfehler ist beim Upload aufgetreten.');
} finally {
this.uploading = false;
}
},
async deleteDocumentation(file) {
try {
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphCompany/deleteDocumentation`, {
documentationId: file.id
});
if (data.success) {
window.notify('success', data.message);
await this.fetchData();
} else {
window.notify('error', data.message || 'Löschen fehlgeschlagen.');
}
} catch (e) {
window.notify('error', 'Netzwerkfehler beim Löschen.');
}
},
async completeWorkorder() {
this.completing = true;
try {
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphCompany/completeWorkorder`, {
workorderId: this.workorderMphId
});
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 acceptDocumentation() {
try {
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphAdmin/acceptDocumentation`, {
workorderId: this.workorderMphId
});
if (data.success) {
window.notify('success', data.message);
this.$emit('documentation-accepted');
this.showAcceptModal = false;
} else {
window.notify('error', data.message || 'Akzeptieren fehlgeschlagen.');
}
} catch (e) {
window.notify('error', 'Ein Netzwerkfehler ist aufgetreten.');
}
}
},
async mounted() {
await this.fetchData();
}
});