added mph workorders to thetool

This commit is contained in:
Luca Haid
2025-11-16 19:19:17 +01:00
parent 411066bead
commit fadf3ccd8d
12 changed files with 2250 additions and 0 deletions
@@ -0,0 +1,237 @@
// WorkorderMphAdmin.js
Vue.component('workorder-mph-admin', {
template: `
<tt-card>
<tt-table-crud ref="table" :crud-config="crudConfig">
<template v-slot:hausnummerinfo="{ row }">
<div class="small">
<div><strong>Adresse:</strong> {{ row.street }} {{ row.hausnummer }}<template v-if="row.stiege">/{{ row.stiege }}</template></div>
<div><strong>Ort:</strong> {{ row.plz }} {{ row.city }}</div>
<div><strong>Wohneinheiten:</strong> {{ row.wohneinheitCount }}</div>
</div>
</template>
<template v-slot:status="{ row }">
<traffic-light-mph :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:companyname="{ row }">
<div class="d-flex justify-content-between align-items-center">
<div class="flex-grow-1">
<div v-if="editingWorkorderId === row.id">
<div v-if="companiesLoading" class="spinner-border spinner-border-sm"></div>
<tt-select v-else :options="companies" :value="row.companyId"
@input="assignCompany(row, $event)" @blur="editingWorkorderId = null"
@keydown.esc.native="editingWorkorderId = null"
placeholder="Firma zuweisen..." sm no-form-group/>
</div>
<div v-else-if="row.status === 'new'">
<tt-select :options="companies" :value="row.companyId"
@input="assignCompany(row, $event)" @focus="loadCompanies"
placeholder="Firma zuweisen..." sm no-form-group/>
</div>
<div v-else><span>{{ row.companyName || 'N/A' }}</span></div>
</div>
<div style="display: grid; grid-template-columns: repeat(2, auto); gap: 0px; padding-left: 8px;">
<tt-button v-if="!['completed', 'new'].includes(row.status)" icon="fas fa-edit"
@click="startCompanyEdit(row)" additional-class="btn-link workorder-mph-button"
title="Zuweisung ändern"/>
<tt-button v-if="!['completed', 'cancelled'].includes(row.status)" icon="fas fa-ban text-danger"
@click="cancelWorkorderModalData = row" additional-class="btn-link workorder-mph-button"
title="Auftrag stornieren"/>
</div>
</div>
</template>
<template v-slot:additionalinfo="{ row }">
<div v-if="editingAdditionalInfoId === row.id">
<tt-textarea v-model="tempAdditionalInfo" @keydown.esc.native="cancelEdit" rows="3" no-form-group sm ref="editTextarea"/>
<div class="mt-2 d-flex justify-content-end">
<tt-button text="Abbrechen" @click="cancelEdit" sm additional-class="btn-secondary mr-2"/>
<tt-button text="Speichern" @click="updateAdditionalInfo(row)" sm additional-class="btn-success"/>
</div>
</div>
<div v-else class="d-flex align-items-start">
<span style="white-space: pre-wrap; max-width: 250px; display: inline-block;">{{ row.additionalInfo || '-' }}</span>
<tt-button icon="fas fa-edit" @click="startAdditionalInfoEdit(row)"
additional-class="btn-link btn-sm p-0 ml-2" title="Zusatz-Info bearbeiten"/>
</div>
</template>
<template v-slot:deadlinedate="{ row }">
<div v-if="editingDeadlineId === row.id">
<tt-date-picker :value="row.deadlineDate" :date-range="false"
@input="updateDeadline(row, $event)" @blur="editingDeadlineId = null"
sm no-form-group/>
</div>
<div v-else class="d-flex align-items-center">
<span>{{ formatDate(row.deadlineDate) }}</span>
<tt-button icon="fas fa-edit" @click="editingDeadlineId = row.id"
additional-class="btn-link btn-sm p-0 ml-2" title="Deadline ändern"/>
</div>
</template>
<template v-slot:appointmentdate="{ row }">{{ formatDate(row.appointmentDate, true) }}</template>
<template v-slot:expandedRow="{ row }">
<div class="row">
<div class="col-12">
<wohneinheit-status-manager :workorder-mph-id="parseInt(row.id)" :is-admin="true"/>
</div>
<div class="col-12 mt-3">
<checkbox-documentation :workorder-mph-id="parseInt(row.id)" :is-admin="true"/>
</div>
<div class="col-12 mt-3">
<workorder-mph-details-manager :workorder-mph-id="row.id" :is-admin="true"
@documentation-accepted="$refs.table.$refs.table.refreshTable()"/>
</div>
</div>
</template>
</tt-table-crud>
<tt-modal v-if="cancelWorkorderModalData" :show.sync="cancelWorkorderModalData"
title="Auftrag stornieren" @submit="cancelWorkorder">
<p>Soll der Auftrag <strong>#{{ cancelWorkorderModalData.id }}</strong> wirklich storniert werden?</p>
<tt-textarea label="Grund (optional)" v-model="cancelWorkorderModalData.reason" sm row/>
</tt-modal>
</tt-card>
`,
data() {
return {
window,
editingWorkorderId: null,
editingDeadlineId: null,
editingAdditionalInfoId: null,
tempAdditionalInfo: '',
companies: [],
companiesLoading: false,
cancelWorkorderModalData: null,
crudConfig: {
...window.TT_CONFIG.CRUD_CONFIG,
selectable: false,
expandable: true,
customRowClass: (row) => {
if (['completed', 'new', 'cancelled', 'archived'].includes(row.status)) return 'tt-mph-workorder-irrelevant';
const deadlineDate = moment.unix(row.deadlineDate);
if (!deadlineDate.isValid()) return 'tt-mph-workorder-irrelevant';
const daysLeft = deadlineDate.diff(moment(), 'days');
if (daysLeft <= 7) return 'tt-mph-workorder-urgent';
if (daysLeft <= 21) return 'tt-mph-workorder-medium';
return 'tt-mph-workorder-ontrack';
}
}
}
},
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 loadCompanies() {
if (this.companies.length > 0) return;
this.companiesLoading = true;
try {
const { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphAdmin/getCompanies`);
this.companies = data;
} catch (e) {
window.notify('error', 'Firmenliste konnte nicht geladen werden.');
} finally {
this.companiesLoading = false;
}
},
async startCompanyEdit(row) {
await this.loadCompanies();
this.editingWorkorderId = row.id;
},
async assignCompany(workorder, companyId) {
if (!companyId) {
this.editingWorkorderId = null;
return;
}
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphAdmin/assignWorkorder`, {
workorderId: workorder.id,
companyId: companyId
});
if (data.success) {
window.notify('success', data.message);
this.$refs.table.$refs.table.refreshTable();
} else {
window.notify('error', data.message || 'Ein Fehler ist aufgetreten.');
}
this.editingWorkorderId = null;
},
async updateDeadline(workorder, newDate) {
if (!newDate) {
this.editingDeadlineId = null;
return;
}
try {
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphAdmin/updateDeadline`, {
workorderId: workorder.id,
deadlineDate: newDate
});
if (data.success) {
window.notify('success', data.message);
this.$refs.table.$refs.table.refreshTable();
} else {
window.notify('error', data.message || 'Ein Fehler ist aufgetreten.');
}
} catch (e) {
window.notify('error', 'Netzwerkfehler.');
} finally {
this.editingDeadlineId = null;
}
},
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) {
if (row.additionalInfo === this.tempAdditionalInfo) {
this.cancelEdit();
return;
}
try {
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphAdmin/updateAdditionalInfo`, {
workorderMphId: row.id,
additionalInfo: this.tempAdditionalInfo
});
if (data.success) {
window.notify('success', data.message);
row.additionalInfo = data.newInfo;
} else {
window.notify('error', data.message || 'Update fehlgeschlagen.');
}
} catch (e) {
window.notify('error', 'Netzwerkfehler.');
} finally {
this.cancelEdit();
}
},
async cancelWorkorder() {
const { id, reason } = this.cancelWorkorderModalData;
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphAdmin/cancelWorkorder`, {
workorderId: id,
reason: reason
});
if (data.success) {
window.notify('success', data.message);
this.$refs.table.$refs.table.refreshTable();
this.cancelWorkorderModalData = null;
} else {
window.notify('error', data.message || 'Stornierung fehlgeschlagen.');
}
}
}
});
@@ -0,0 +1,208 @@
/*
* CSS for WorkorderMph Table Row Highlighting
*/
/* Urgent: Deadline passed or less than 1 week away */
.table-hover .tt-mph-workorder-urgent:hover,
.tt-mph-workorder-urgent {
background-color: #fbe9e7 !important; /* Soft Red */
}
/* Medium: Deadline less than 3 weeks away */
.table-hover .tt-mph-workorder-medium:hover,
.tt-mph-workorder-medium {
background-color: #fff8e1 !important; /* Soft Yellow */
}
/* On Track: Deadline more than 3 weeks away */
.table-hover .tt-mph-workorder-ontrack:hover,
.tt-mph-workorder-ontrack {
background-color: #e8f5e9 !important; /* Soft Green */
}
/* Irrelevant: No deadline or status makes it not applicable */
.table-hover .tt-mph-workorder-irrelevant:hover,
.tt-mph-workorder-irrelevant {
background-color: #fafafa !important; /* Very light grey */
}
.table-hover .tt-mph-workorder-high:hover,
.tt-mph-workorder-high {
background-color: #f8d7da !important; /* A slightly more intense red for high priority issues */
}
/*
* Wohneinheit Manager - Dense Table Layout
*/
.wohneinheit-manager .we-table {
display: table;
width: 100%;
border-collapse: collapse;
}
.wohneinheit-manager .we-row {
display: table-row;
border-bottom: 1px solid #e9ecef;
transition: background-color 0.15s ease;
}
.wohneinheit-manager .we-row:hover {
background-color: #f8f9fa;
}
.wohneinheit-manager .we-cell {
display: table-cell;
padding: 8px 12px;
vertical-align: middle;
}
.wohneinheit-manager .we-bezeichner {
width: 25%;
font-size: 0.9rem;
}
.wohneinheit-manager .we-status {
width: 20%;
}
.wohneinheit-manager .we-note {
width: 40%;
}
.wohneinheit-manager .we-actions {
width: 15%;
text-align: right;
}
.contact-info {
font-size: 0.85rem;
margin-top: 4px;
padding-left: 4px;
border-left: 2px solid #007bff;
}
.workorder-mph-button {
padding: 2px !important;
}
/*
* Custom Checkboxes - Compact & Beautiful
*/
.custom-checkboxes-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 8px 16px;
}
.custom-checkbox-item {
display: flex;
align-items: center;
padding: 8px 12px;
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
margin: 0;
user-select: none;
}
.custom-checkbox-item:hover:not(.disabled) {
background: #e9ecef;
border-color: #007bff;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.custom-checkbox-item.disabled {
opacity: 0.6;
cursor: not-allowed;
}
.custom-checkbox-item input[type="checkbox"] {
position: absolute;
opacity: 0;
cursor: pointer;
}
.custom-checkbox-item .checkmark {
position: relative;
height: 20px;
width: 20px;
background-color: #fff;
border: 2px solid #adb5bd;
border-radius: 4px;
margin-right: 10px;
flex-shrink: 0;
transition: all 0.2s ease;
}
.custom-checkbox-item input[type="checkbox"]:checked ~ .checkmark {
background-color: #28a745;
border-color: #28a745;
}
.custom-checkbox-item .checkmark:after {
content: "";
position: absolute;
display: none;
left: 6px;
top: 2px;
width: 5px;
height: 10px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.custom-checkbox-item input[type="checkbox"]:checked ~ .checkmark:after {
display: block;
}
.custom-checkbox-item .checkbox-label {
font-size: 0.9rem;
font-weight: 500;
color: #495057;
}
.custom-checkbox-item input[type="checkbox"]:checked ~ .checkbox-label {
color: #28a745;
}
/*
* Required Documents Checklist
*/
.required-docs-checklist {
background: #f8f9fa;
border-radius: 6px;
padding: 8px;
}
.doc-check-item {
display: flex;
align-items: center;
padding: 6px 10px;
margin-bottom: 4px;
background: white;
border-radius: 4px;
font-size: 0.9rem;
gap: 10px;
}
.doc-check-item:last-child {
margin-bottom: 0;
}
.doc-check-item i:first-child {
width: 20px;
text-align: center;
}
.doc-check-item span {
flex: 1;
font-weight: 500;
}
.doc-check-item .ml-auto {
margin-left: auto;
}
@@ -0,0 +1,543 @@
// 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">
<h5 class="mb-0"><i class="fas fa-building mr-2"></i>Wohneinheiten Status</h5>
</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">
<div class="we-cell we-bezeichner">
<strong>{{ we.bezeichner }}</strong>
<div v-if="we.contact" class="contact-info text-muted">
<i class="fas fa-user mr-1"></i>{{ we.contact }}
</div>
<div v-else class="text-muted small fst-italic">
<i class="fas fa-user-slash mr-1"></i>Keine Kontaktinfo
</div>
</div>
<div class="we-cell we-status">
<tt-select v-model="we.status" :options="statusOptions" sm no-form-group
:disabled="isAdmin" @input="markAsChanged(we)"/>
</div>
<div class="we-cell we-note">
<tt-textarea v-model="we.note" sm rows="1" no-form-group
:disabled="isAdmin"
@input="markAsChanged(we)"
:placeholder="isAdmin ? 'Keine Notiz' : 'Pflichtfeld'"/>
</div>
<div class="we-cell we-actions">
<span v-if="we.changed" class="text-warning small">
<i class="fas fa-exclamation-triangle"></i>
</span>
<tt-button v-if="!isAdmin" @click="saveWohneinheit(we)" text="Speichern" sm
:loading="we.saving" :disabled="!we.changed"/>
</div>
</div>
</div>
</div>
</div>
`,
data: () => ({
loading: true,
wohneinheiten: [],
statusOptions: []
}),
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, changed: false, saving: false }));
this.statusOptions = data.statusOptions || [];
} catch (e) {
window.notify('error', 'Wohneinheiten konnten nicht geladen werden.');
console.error(e);
} finally {
this.loading = false;
}
},
markAsChanged(we) {
we.changed = true;
},
async saveWohneinheit(we) {
if (!we.note || !we.note.trim()) {
return window.notify('error', 'Bitte eine Notiz eingeben.');
}
we.saving = true;
try {
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphCompany/updateWohneinheit`, {
workorderMphId: this.workorderMphId,
wohneinheitId: we.wohneinheitId,
status: we.status,
note: we.note
});
if (data.success) {
window.notify('success', data.message);
we.changed = false;
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" :class="{ disabled: isAdmin }">
<input type="checkbox" v-model="checkboxes.easement" :disabled="isAdmin">
<span class="checkmark"></span>
<span class="checkbox-label">Leitungsrecht</span>
</label>
<label class="custom-checkbox-item" :class="{ disabled: isAdmin }">
<input type="checkbox" v-model="checkboxes.btb" :disabled="isAdmin">
<span class="checkmark"></span>
<span class="checkbox-label">BTB</span>
</label>
<label class="custom-checkbox-item" :class="{ disabled: isAdmin }">
<input type="checkbox" v-model="checkboxes.fttxLocationSupplied" :disabled="isAdmin">
<span class="checkmark"></span>
<span class="checkbox-label">FTTx Location mit Leerrohr versorgt</span>
</label>
<label class="custom-checkbox-item" :class="{ disabled: isAdmin }">
<input type="checkbox" v-model="checkboxes.conduitToHuepLaid" :disabled="isAdmin">
<span class="checkmark"></span>
<span class="checkbox-label">Leerrohr bis HÜP/HAK verlegt</span>
</label>
<label class="custom-checkbox-item" :class="{ disabled: isAdmin }">
<input type="checkbox" v-model="checkboxes.huepMounted" :disabled="isAdmin">
<span class="checkmark"></span>
<span class="checkbox-label">HÜP/HAK montiert</span>
</label>
<label class="custom-checkbox-item" :class="{ disabled: isAdmin }">
<input type="checkbox" v-model="checkboxes.dropCableAvailable" :disabled="isAdmin">
<span class="checkmark"></span>
<span class="checkbox-label">Dropkabel vorhanden</span>
</label>
</div>
<div class="mt-3 text-right" v-if="!isAdmin">
<tt-button @click="saveCheckboxes" text="Speichern"
:loading="saving" additional-class="btn-primary btn-sm"/>
</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 { data } = await axios.get(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphCompany/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 { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphCompany/updateCheckboxes`, {
workorderMphId: this.workorderMphId,
...this.checkboxes
});
if (data.success) {
window.notify('success', data.message);
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>
<div class="form-group row mb-2">
<label class="col-form-label col-sm-4 col-form-label-sm">Dokumententyp*</label>
<div class="col-sm-8">
<select v-model="uploadData.documentType" class="form-control form-control-sm" required>
<option value="">-- Bitte wählen --</option>
<option v-for="doc in requiredDocs" :key="doc.key" :value="doc.key">
{{ doc.label }}
</option>
</select>
<small v-if="uploadData.documentType" class="form-text text-muted">
<i class="fas fa-info-circle"></i> {{ getDocExample(uploadData.documentType) }}
</small>
</div>
</div>
<div class="form-group row mb-2">
<label class="col-form-label col-sm-4 col-form-label-sm">Beschreibung</label>
<div class="col-sm-8">
<input type="text" v-model="uploadData.description" class="form-control form-control-sm"
placeholder="Optional: Zusätzliche Beschreibung"/>
</div>
</div>
<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"
@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;
}
},
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: 'photo', 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();
}
});
@@ -0,0 +1,237 @@
// WorkorderMphCompany.js
Vue.component('workorder-mph-company', {
template: `
<tt-card>
<tt-table-crud ref="table" :crud-config="crudConfig">
<template v-slot:hausnummerinfo="{ row }">
<div class="small">
<div><strong>Adresse:</strong> {{ row.street }} {{ row.hausnummer }}<template v-if="row.stiege">/{{ row.stiege }}</template></div>
<div><strong>Ort:</strong> {{ row.plz }} {{ row.city }}</div>
<div><strong>Wohneinheiten:</strong> {{ row.wohneinheitCount }}</div>
</div>
</template>
<template v-slot:status="{ row }">
<traffic-light-mph :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:deadlinedate="{ row }">{{ formatDate(row.deadlineDate) }}</template>
<template v-slot:appointmentdate="{ row }">
<div v-if="editingAppointmentId === row.id">
<tt-date-picker :value="row.appointmentDate" :date-range="false" time-picker
@input="scheduleAppointment(row, $event)" @blur="editingAppointmentId = null"
sm no-form-group/>
</div>
<div v-else class="d-flex align-items-center">
<span>{{ formatDate(row.appointmentDate, true) }}</span>
<tt-button v-if="canSchedule(row)" icon="fas fa-edit"
@click="editingAppointmentId = row.id"
additional-class="btn-link btn-sm p-0 ml-2" title="Termin planen"/>
</div>
</template>
<template v-slot:additionalinfo="{ row }">
<span style="white-space: pre-wrap; max-width: 250px; display: inline-block;">{{ row.additionalInfo || '-' }}</span>
</template>
<template v-slot:expandedRow="{ row }">
<div class="row">
<!-- Action Buttons -->
<div class="col-12 mb-3" v-if="!['completed', 'cancelled', 'documented'].includes(row.status)">
<div class="btn-group" role="group">
<tt-button v-if="row.status === 'assigned'" text="Termin planen"
@click="editingAppointmentId = row.id" icon="fas fa-calendar-plus"
additional-class="btn-primary"/>
<tt-button v-if="row.status === 'scheduled'" text="Arbeit beginnen"
@click="startWork(row)" icon="fas fa-play" additional-class="btn-success"/>
<tt-button v-if="row.status === 'scheduled'" text="Termin verschieben"
@click="openRescheduleModal(row)" icon="fas fa-calendar-alt"
additional-class="btn-warning"/>
<tt-button v-if="row.status === 'in_progress'" text="Auftrag abschließen"
@click="openCompleteModal(row)" icon="fas fa-check-double"
additional-class="btn-success"/>
</div>
</div>
<!-- Wohneinheit Manager -->
<div class="col-12">
<wohneinheit-status-manager :workorder-mph-id="parseInt(row.id)" :is-admin="false"
@wohneinheit-updated="checkAllWohneinheitenHaveNotes(row.id)"/>
</div>
<!-- Checkbox Documentation -->
<div class="col-12 mt-3">
<checkbox-documentation :workorder-mph-id="parseInt(row.id)" :is-admin="false"/>
</div>
<!-- Details Manager (Docs & Journal) -->
<div class="col-12 mt-3">
<workorder-mph-details-manager :workorder-mph-id="row.id" :is-admin="false"
@workorder-completed="$refs.table.$refs.table.refreshTable()"/>
</div>
</div>
</template>
</tt-table-crud>
<tt-modal v-if="rescheduleModalData" :show.sync="rescheduleModalData"
title="Termin verschieben" @submit="rescheduleAppointment">
<p>Aktueller Termin: <strong>{{ formatDate(rescheduleModalData.currentDate, true) }}</strong></p>
<tt-date-picker label="Neuer Termin" v-model="rescheduleModalData.newDate"
:date-range="false" time-picker sm row required/>
<tt-textarea label="Grund" v-model="rescheduleModalData.reason" sm row required/>
</tt-modal>
<tt-modal v-if="completeModalData" :show.sync="completeModalData"
title="Auftrag abschließen" @submit="completeWorkorder" :delete="false">
<p>Möchten Sie diesen Auftrag wirklich abschließen und zur Prüfung einreichen?</p>
<div class="alert alert-warning small">
<i class="fas fa-exclamation-triangle mr-2"></i>
Bitte stellen Sie sicher, dass alle Wohneinheiten dokumentiert sind und alle erforderlichen Dokumente hochgeladen wurden.
</div>
</tt-modal>
</tt-card>
`,
data() {
return {
window,
editingAppointmentId: null,
rescheduleModalData: null,
completeModalData: null,
crudConfig: {
...window.TT_CONFIG.CRUD_CONFIG,
selectable: false,
expandable: true,
customRowClass: (row) => {
if (['completed', 'new', 'cancelled'].includes(row.status)) return 'tt-mph-workorder-irrelevant';
const deadlineDate = moment.unix(row.deadlineDate);
if (!deadlineDate.isValid()) return 'tt-mph-workorder-irrelevant';
const daysLeft = deadlineDate.diff(moment(), 'days');
if (daysLeft <= 7) return 'tt-mph-workorder-urgent';
if (daysLeft <= 21) return 'tt-mph-workorder-medium';
return 'tt-mph-workorder-ontrack';
}
}
}
},
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');
},
canSchedule(row) {
return ['assigned', 'scheduled'].includes(row.status);
},
async scheduleAppointment(row, newDate) {
if (!newDate) {
this.editingAppointmentId = null;
return;
}
const hour = parseInt(moment.unix(newDate).format('H'));
if (hour >= 23 || hour < 1) {
window.notify('error', 'Bitte geben Sie eine Uhrzeit zwischen 01:00 und 22:59 an!');
return;
}
try {
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphCompany/scheduleAppointment`, {
workorderId: row.id,
appointmentDate: newDate
});
if (data.success) {
window.notify('success', data.message);
this.$refs.table.$refs.table.refreshTable();
} else {
window.notify('error', data.message || 'Ein Fehler ist aufgetreten.');
}
} catch (e) {
window.notify('error', 'Netzwerkfehler.');
} finally {
this.editingAppointmentId = null;
}
},
openRescheduleModal(row) {
this.rescheduleModalData = {
workorderId: row.id,
currentDate: row.appointmentDate,
newDate: null,
reason: ''
};
},
async rescheduleAppointment() {
if (!this.rescheduleModalData.newDate || !this.rescheduleModalData.reason) {
return window.notify('error', 'Bitte füllen Sie alle Felder aus.');
}
const hour = parseInt(moment.unix(this.rescheduleModalData.newDate).format('H'));
if (hour >= 23 || hour < 1) {
window.notify('error', 'Bitte geben Sie eine Uhrzeit zwischen 01:00 und 22:59 an!');
return;
}
try {
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphCompany/rescheduleAppointment`, {
workorderId: this.rescheduleModalData.workorderId,
appointmentDate: this.rescheduleModalData.newDate,
reason: this.rescheduleModalData.reason
});
if (data.success) {
window.notify('success', data.message);
this.$refs.table.$refs.table.refreshTable();
this.rescheduleModalData = null;
} else {
window.notify('error', data.message || 'Ein Fehler ist aufgetreten.');
}
} catch (e) {
window.notify('error', 'Netzwerkfehler.');
}
},
async startWork(row) {
if (!confirm(`Möchten Sie mit der Arbeit an Auftrag #${row.id} beginnen?`)) return;
try {
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphCompany/startWork`, {
workorderId: row.id
});
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', 'Netzwerkfehler.');
}
},
openCompleteModal(row) {
this.completeModalData = { workorderId: row.id };
},
async completeWorkorder() {
try {
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderMphCompany/completeWorkorder`, {
workorderId: this.completeModalData.workorderId
});
if (data.success) {
window.notify('success', data.message);
this.$refs.table.$refs.table.refreshTable();
this.completeModalData = null;
} else {
window.notify('error', data.message || 'Abschluss fehlgeschlagen.');
}
} catch (e) {
window.notify('error', 'Ein Netzwerkfehler ist aufgetreten.');
}
},
async checkAllWohneinheitenHaveNotes(workorderId) {
// This is called when a wohneinheit is updated
// Could be used to enable/disable the complete button
}
}
});