added new rml features

This commit is contained in:
2025-08-12 16:26:37 +02:00
parent 8045a80b50
commit ffed33c4b2
5 changed files with 428 additions and 98 deletions
@@ -39,7 +39,7 @@ Vue.component('r-m-l-workorder-admin', {
additional-class="btn-link btn-sm p-0 m-0"
title="Zur Bestellung"
/>
</div>
</div>
</template>
@@ -103,10 +103,28 @@ Vue.component('r-m-l-workorder-admin', {
</template>
<template v-slot:deadlinedate="{ row }">
{{ formatDate(row.deadlineDate) }}
<span v-if="row.daysUntilDeadline !== null && row.daysUntilDeadline >= 0" class="ml-2 text-muted small">
bis zum Termin: {{ row.daysUntilDeadline }} Tag{{ row.daysUntilDeadline !== 1 ? 'e' : '' }}
<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>
<span v-if="row.daysUntilDeadline !== null && row.daysUntilDeadline >= 0" class="ml-2 text-muted small">
übrig: {{ row.daysUntilDeadline }} Tag{{ row.daysUntilDeadline !== 1 ? 'e' : '' }}
</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 }">
@@ -117,6 +135,7 @@ Vue.component('r-m-l-workorder-admin', {
<rml-documentation-viewer-admin
:workorder-id="row.id"
@workorder-updated="$refs.table.$refs.table.refreshTable()"
@accept-documentation="acceptDocumentation"
/>
</template>
@@ -128,6 +147,7 @@ Vue.component('r-m-l-workorder-admin', {
window,
workordersToAssign: [],
editingWorkorderId: null,
editingDeadlineId: null,
companies: [],
massAssignCompanyId: null,
massAssignLoading: false,
@@ -141,8 +161,7 @@ Vue.component('r-m-l-workorder-admin', {
if (['completed', 'new'].includes(row.status) || !deadlineDate.isValid()) {
return 'tt-rml-workorder-irrelevant';
}
// if status is correction_requested, return tt-rml-workorder-high
if (row.status === 'correction_requested') {
if (['correction_requested', 'intervention_required'].includes(row.status)) {
return 'tt-rml-workorder-high';
}
@@ -231,6 +250,43 @@ Vue.component('r-m-l-workorder-admin', {
this.massAssignLoading = false;
this.massAssignCompanyId = null;
}
},
async updateDeadline(workorder, newDate) {
if (!newDate) {
this.editingDeadlineId = null;
return;
}
try {
const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderAdmin/updateDeadline`, {
workorderId: workorder.id,
deadlineDate: newDate
});
if (response.data.success) {
window.notify('success', response.data.message);
this.$refs.table.$refs.table.refreshTable();
} else {
window.notify('error', response.data.message || 'Ein Fehler ist aufgetreten.');
}
} catch (e) {
window.notify('error', 'Ein Netzwerkfehler ist aufgetreten.');
} finally {
this.editingDeadlineId = null;
}
},
async acceptDocumentation(workorderId) {
if (!confirm('Soll die Dokumentation für diesen Arbeitsauftrag wirklich akzeptiert und der Auftrag abgeschlossen werden?')) return;
try {
const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderAdmin/acceptDocumentation`, { workorderId });
if (response.data.success) {
window.notify('success', response.data.message);
this.$refs.table.$refs.table.refreshTable();
} else {
window.notify('error', response.data.message || 'Ein Fehler ist aufgetreten.');
}
} catch (e) {
window.notify('error', 'Ein Netzwerkfehler ist aufgetreten.');
}
}
}
});
@@ -273,17 +329,16 @@ Vue.component('rml-documentation-viewer-admin', {
</div>
</div>
<!-- add a new card with a green button Dokumentation akzeptieren-->
<div class="card mb-3">
<div class="card-header"><h5><i class="fas fa-check-circle text-success mr-2"></i>Dokumentation akzeptieren</h5></div>
<div class="card-body">
<p class="small text-muted">Wenn die Dokumentation korrekt ist, können Sie sie hier akzeptieren.</p>
<tt-button text="Dokumentation akzeptieren"
@click="$emit('accept-documentation', workorderId)"
additional-class="btn-success float-right"
icon="fas fa-check"
/>
</div>
<div class="card-header"><h5><i class="fas fa-check-circle text-success mr-2"></i>Dokumentation akzeptieren</h5></div>
<div class="card-body">
<p class="small text-muted">Wenn die Dokumentation korrekt ist, können Sie sie hier akzeptieren.</p>
<tt-button text="Dokumentation akzeptieren"
@click="$emit('accept-documentation', workorderId)"
additional-class="btn-success float-right"
icon="fas fa-check"
/>
</div>
</div>
<div class="card mt-3">
@@ -1,50 +1,76 @@
// RMLWorkorderCompany.js
Vue.component('r-m-l-workorder-company', {
template: `
<tt-card>
<tt-table-crud
ref="table"
:crud-config="crudConfig"
>
<template v-slot:preorderinfo="{ row }">
<div v-html="row.preorderInfo" class="small"></div>
</template>
<div>
<tt-card>
<tt-table-crud
ref="table"
:crud-config="crudConfig"
>
<template v-slot:preorderinfo="{ row }">
<div v-html="row.preorderInfo" class="small"></div>
</template>
<template v-slot:status="{ row }">
<traffic-light :deadline="row.deadlineDate" :status="row.status" />
<i :class="getStatusColumn(row.status).icon" :title="getStatusColumn(row.status).text"></i>
<span class="ml-2">{{ getStatusColumn(row.status).text }}</span>
</template>
<template v-slot:status="{ row }">
<traffic-light :deadline="row.deadlineDate" :status="row.status"/>
<i :class="getStatusColumn(row.status).icon" :title="getStatusColumn(row.status).text"></i>
<span class="ml-2">{{ getStatusColumn(row.status).text }}</span>
</template>
<template v-slot:deadlinedate="{ row }">
{{ formatDate(row.deadlineDate) }}
</template>
<template v-slot:deadlinedate="{ row }">
{{ formatDate(row.deadlineDate) }}
</template>
<template v-slot:appointmentdate="{ row }">
<div v-if="!row.appointmentDate && ['assigned', 'correction_requested'].includes(row.status)">
<tt-date-picker
placeholder="Termin festlegen..."
:date-range="false"
@input="setAppointment(row, $event)"
sm
no-form-group
<template v-slot:appointmentdate="{ row }">
<div v-if="!row.appointmentDate && ['assigned', 'correction_requested'].includes(row.status)">
<tt-date-picker
placeholder="Termin festlegen..."
:date-range="false"
@input="setAppointment(row, $event)"
sm
no-form-group
:additional-props="{ timePicker: true, timePicker24Hour: true, locale: { format: 'DD.MM.YYYY HH:mm' }, drops: 'up' }"
/>
</div>
<div v-else-if="row.appointmentDate">
<span>{{ formatDate(row.appointmentDate, true) }}</span>
<tt-button
icon="fas fa-edit"
@click="openRescheduleModal(row)"
additional-class="btn-link btn-sm p-0 ml-2"
title="Termin ändern"
/>
</div>
<span v-else></span>
</template>
<template v-slot:expandedRow="{ row }">
<documentation-manager
:workorder-id="row.id"
@workorder-completed="$refs.table.$refs.table.refreshTable()"
/>
</div>
<span v-else>{{ formatDate(row.appointmentDate) }}</span>
</template>
</template>
<template v-slot:expandedRow="{ row }">
<documentation-manager
:workorder-id="row.id"
@workorder-completed="$refs.table.$refs.table.refreshTable()"
/>
</template>
</tt-table-crud>
</tt-card>
</tt-table-crud>
</tt-card>
<tt-modal v-if="rescheduleData" :show="true" title="Termin verschieben" @close="closeRescheduleModal" @submit="rescheduleAppointment">
<p><strong>Auftrag:</strong> #{{ rescheduleData.workorder.id }}</p>
<tt-date-picker
label="Neuer Termin"
:date-range="false"
v-model="rescheduleData.newDate"
sm
row
:additional-props="{ timePicker: true, timePicker24Hour: true, locale: { format: 'DD.MM.YYYY HH:mm' }, singleDatePicker: true }"
/>
<tt-textarea label="Grund" v-model="rescheduleData.reason" sm row required/>
</tt-modal>
</div>
`,
data() {
return {
rescheduleData: null,
crudConfig: {
...window.TT_CONFIG.CRUD_CONFIG,
expandable: true,
@@ -55,6 +81,10 @@ Vue.component('r-m-l-workorder-company', {
return 'tt-rml-workorder-irrelevant';
}
if (['correction_requested', 'intervention_required'].includes(row.status)) {
return 'tt-rml-workorder-high';
}
const daysLeft = deadlineDate.diff(moment(), 'days');
if (daysLeft <= 7) return 'tt-rml-workorder-urgent';
@@ -71,12 +101,20 @@ Vue.component('r-m-l-workorder-company', {
const column = this.crudConfig.columns.find(c => c.key === 'status');
return column.table.filterOptions.find(opt => opt.value === status) || {};
},
formatDate(timestamp) {
formatDate(timestamp, withTime = false) {
if (!timestamp) return '';
return window.moment.unix(timestamp).format('DD.MM.YYYY');
const format = withTime ? 'DD.MM.YYYY HH:mm' : 'DD.MM.YYYY';
return window.moment.unix(timestamp).format(format);
},
async setAppointment(workorder, date) {
if (!date) return;
const hour = moment.unix(date).hour();
if (hour >= 23 || hour < 1) {
this.$refs.table.$refs.table.refreshTable(); // Re-render to clear invalid date from picker
return window.notify('error', 'Bitte Uhrzeit angeben!');
}
try {
const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderCompany/scheduleAppointment`, {
workorderId: workorder.id,
@@ -89,7 +127,44 @@ Vue.component('r-m-l-workorder-company', {
window.notify('error', response.data.message || 'Ein Fehler ist aufgetreten.');
}
} catch (e) {
window.notify('error', 'Ein Netzwerkfehler ist aufgetreten.');
window.notify('error', e.response?.data?.message || 'Ein Netzwerkfehler ist aufgetreten.');
}
},
openRescheduleModal(row) {
this.rescheduleData = {
workorder: row,
newDate: row.appointmentDate,
reason: ''
};
},
closeRescheduleModal() {
this.rescheduleData = null;
},
async rescheduleAppointment() {
const { workorder, newDate, reason } = this.rescheduleData;
if (!newDate || !reason) {
return window.notify('error', 'Bitte geben Sie ein neues Datum und einen Grund an.');
}
const hour = moment.unix(newDate).hour();
if (hour >= 23 || hour < 1) {
return window.notify('error', 'Bitte Uhrzeit angeben!');
}
try {
const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderCompany/rescheduleAppointment`, {
workorderId: workorder.id,
appointmentDate: newDate,
reason: reason
});
if (response.data.success) {
window.notify('success', response.data.message);
this.$refs.table.$refs.table.refreshTable();
this.closeRescheduleModal();
} else {
window.notify('error', response.data.message || 'Ein Fehler ist aufgetreten.');
}
} catch (e) {
window.notify('error', e.response?.data?.message || 'Ein Netzwerkfehler ist aufgetreten.');
}
}
}
@@ -135,31 +210,45 @@ Vue.component('documentation-manager', {
<tt-button
text="Auftrag abschließen"
@click="completeWorkorder"
:disabled="!canComplete || workorder.status === 'completed'"
:disabled="!canComplete || workorder.status === 'documented' || workorder.status === 'completed'"
:loading="completing"
additional-class="btn-success w-100"
icon="fas fa-check-double"
/>
<small v-if="!canComplete" class="form-text text-muted text-center mt-2">
<small v-if="!canComplete && workorder.status !== 'documented' && workorder.status !== 'completed'" 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="workorder.status === 'completed'" class="alert alert-secondary text-center mt-2 p-2">
Auftrag bereits abgeschlossen.
<div v-if="workorder.status === 'documented' || workorder.status === 'completed'" class="alert alert-secondary text-center mt-2 p-2">
Auftrag zur Prüfung eingereicht.
</div>
</div>
</div>
<div class="card mt-3" v-if="['assigned', 'scheduled', 'correction_requested'].includes(workorder.status)">
<div class="card-header bg-danger text-white"><h5><i class="fas fa-hard-hat mr-2"></i>Eingriff benötigt</h5></div>
<div class="card-body">
<p class="small text-muted">Falls ein Problem auftritt, das ein Eingreifen erfordert, melden Sie es hier.</p>
<tt-button
text="Problem melden"
@click="openInterventionModal"
additional-class="btn-danger w-100"
icon="fas fa-exclamation-triangle"
/>
</div>
</div>
<div class="card mt-3">
<div class="card-header"><h5><i class="fas fa-history mr-2"></i>Journal</h5></div>
<div class="card-body p-0" style="max-height: 200px; overflow-y: auto;">
<ul class="list-group list-group-flush">
<li v-if="journals.length === 0" class="list-group-item text-center text-muted">Keine Einträge vorhanden.</li>
<li v-for="log in journals" :key="log.id" class="list-group-item small" :class="{'list-group-item-danger': log.statusChange && log.statusChange.includes('correction_requested')}">
<li v-for="log in journals" :key="log.id" class="list-group-item small" :class="{'list-group-item-danger': log.statusChange && (log.statusChange.includes('correction_requested') || log.statusChange.includes('intervention_required'))}">
<strong>{{ formatDate(log.create) }} ({{ log.createByName }}):</strong>
<div class="text-muted" style="white-space: pre-wrap;">{{ log.text }}</div>
</li>
</ul>
</div>
<div class="card-footer" v-if="workorder.status !== 'completed'">
<div class="card-footer" v-if="workorder.status !== 'completed' && workorder.status !== 'documented'">
<tt-textarea v-model="newJournalMessage" placeholder="Ihre Nachricht oder Anmerkung..." rows="2" />
<tt-button
text="Eintrag speichern"
@@ -174,7 +263,7 @@ Vue.component('documentation-manager', {
</div>
<div class="col-lg-8">
<div class="card mb-3" v-if="workorder.status !== 'completed'">
<div class="card mb-3" v-if="workorder.status !== 'documented' && workorder.status !== 'completed'">
<div class="card-body">
<h5 class="card-title">Neues Dokument hochladen</h5>
<tt-select label="Dokumententyp" :options="requiredDocTypes" v-model="uploadData.documentType" sm row />
@@ -191,8 +280,8 @@ Vue.component('documentation-manager', {
<tt-file-gallery
:files="filesWithStatus"
:edit-mode="workorder.status !== 'completed'"
:delete-mode="workorder.status !== 'completed'"
:edit-mode="workorder.status !== 'completed' && workorder.status !== 'documented'"
:delete-mode="workorder.status !== 'completed' && workorder.status !== 'documented'"
@delete-file="deleteDocumentation"
@update-file="updateDocumentation"
>
@@ -207,6 +296,18 @@ Vue.component('documentation-manager', {
</tt-file-gallery>
</div>
</div>
<tt-modal v-if="interventionData" :show="true" title="Eingriff anfordern" @close="interventionData = null" @submit="requestIntervention">
<tt-select
label="Art des Problems"
:options="[{value: 'stuck', text: 'Ab X Laufmeter stecken geblieben'}, {value: 'no_air', text: 'Keine Luftverbindung'}, {value: 'other', text: 'Sonstiges'}]"
v-model="interventionData.type"
sm
row
/>
<tt-input v-if="interventionData.type === 'stuck'" label="Distanz (Meter)" type="number" v-model="interventionData.distance" sm row required />
<tt-textarea v-if="interventionData.type === 'other'" label="Grund" v-model="interventionData.otherReason" sm row required />
</tt-modal>
</div>
`,
data() {
@@ -219,17 +320,19 @@ Vue.component('documentation-manager', {
journals: [],
newJournalMessage: '',
addingJournalEntry: false,
interventionData: null,
uploadData: {
files: [],
documentType: 'photo_before',
documentType: 'photo_hup_mounted',
description: ''
},
requiredDocTypes: [
{ value: 'photo_before', text: 'Foto: Zustand vorher' },
{ value: 'photo_during', text: 'Foto: Während der Arbeit' },
{ value: 'photo_after', text: 'Foto: Zustand nachher' },
{ value: 'measurement_protocol', text: 'Messprotokoll (z.B. OTDR)' },
{ value: 'customer_signature', text: 'Unterschriebenes Arbeitsprotokoll' },
{ value: 'photo_hup_mounted', text: 'Foto vom montierten HÜP' },
{ value: 'photo_hup_open', text: 'Foto von dem offen HÜP' },
{ value: 'photo_splice_cassette', text: 'Foto der Spleißkassette' },
{ value: 'photo_hup_closed_stickers', text: 'Foto vom geschlossenen HÜP mit allen Aufklebern' },
{ value: 'photo_fcp_labeled', text: 'Foto vom FCP beschriftet' },
{ value: 'measurement_protocol_otdr', text: 'ODTR Messung (1310nm & 1510nm)' },
]
}
},
@@ -326,7 +429,7 @@ Vue.component('documentation-manager', {
this.uploading = false;
},
async completeWorkorder() {
if(!confirm('Möchten Sie diesen Auftrag wirklich abschließen? Diese Aktion kann nicht rückgängig gemacht werden.')) return;
if(!confirm('Möchten Sie diesen Auftrag wirklich abschließen und zur Prüfung einreichen?')) return;
this.completing = true;
try {
const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderCompany/completeWorkorder`, { workorderId: this.workorder.id });
@@ -390,6 +493,41 @@ Vue.component('documentation-manager', {
} finally {
this.addingJournalEntry = false;
}
},
openInterventionModal() {
this.interventionData = { type: 'stuck', distance: '', otherReason: '' };
},
async requestIntervention() {
let journalText = '';
const { type, distance, otherReason } = this.interventionData;
if (type === 'stuck') {
if (!distance || isNaN(distance)) return window.notify('error', 'Bitte eine gültige Distanz eingeben.');
journalText = `Ab ${distance} Laufmeter stecken geblieben.`;
} else if (type === 'no_air') {
journalText = 'Keine Luftverbindung.';
} else if (type === 'other') {
if (!otherReason.trim()) return window.notify('error', 'Bitte geben Sie einen Grund an.');
journalText = otherReason.trim();
} else {
return window.notify('error', 'Ungültiger Problemtyp.');
}
try {
const response = await axios.post(`${window.TT_CONFIG.BASE_PATH}/RMLWorkorderCompany/requestIntervention`, {
workorderId: this.workorderId,
journalText: journalText
});
if (response.data.success) {
window.notify('success', response.data.message);
this.interventionData = null;
this.$emit('workorder-completed'); // This just refreshes the table
} else {
window.notify('error', response.data.message || 'Ein Fehler ist aufgetreten.');
}
} catch (e) {
window.notify('error', 'Ein Netzwerkfehler ist aufgetreten.');
}
}
},
async mounted() {