Files
thetool/public/js/pages/WorkorderAdmin/WorkorderAdmin.js
AI Development Engine 422de23587 feat: implement issue #9
Resolves luca/thetool#9
Closes #9
2026-03-02 11:23:32 +00:00

467 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.
// WorkorderAdmin.js
Vue.component('workorder-admin', {
template: `
<tt-card>
<div class="mb-2 d-flex align-items-center" v-if="workordersToAssign.length > 0">
<span class="mr-3 font-weight-bold">{{ workordersToAssign.length }} Workorder(s) zuweisen:</span>
<div style="width: 300px;">
<tt-select class="mb-0" :options="companiesForMassAssign" v-model="massAssignCompanyId"
@input="openMassAssignModal" placeholder="Firma auswählen..." sm no-form-group/>
</div>
</div>
<tt-table-crud ref="table" :crud-config="crudConfig">
<template v-slot:preorderinfo="{ row }">
<div class="small">
<div><strong>Kunde:</strong> {{ row.customerCompany || row.customerName }}</div>
<div><strong>Anschluss:</strong> {{ row.street }} {{ row.hausnummer }}<template v-if="row.stiege">/{{ row.stiege }}</template><template v-if="row.apartment"> / WE: {{ row.apartment }}</template>, {{ row.plz }} {{ row.city }}</div>
<div><strong>OAID:</strong> <span class="text-pink">{{ row.oaid }}</span>
<tt-button icon="fas fa-external-link-alt" @click="window.open(window.TT_CONFIG.BASE_PATH + '/Preorder/Index?filter[ucode]=' + row.ucode, '_blank');"
additional-class="btn-link btn-sm p-0 m-0" title="Zur Bestellung"/>
</div>
</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: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="companiesByTenant[row.tenantId] || []" :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="companiesByTenant[row.tenantId] || []" :value="row.companyId"
@input="assignCompany(row, $event)" @focus="getCompaniesForWorkorder(row)"
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-button" title="Zuweisung ändern" />
<tt-button v-if="!workordersToAssign.includes(row.id)" icon="fas fa-plus-circle text-success" @click="addToAssignList(row)"
additional-class="btn-link workorder-button" title="Zur Zuweisungsliste hinzufügen" />
<tt-button v-if="workordersToAssign.includes(row.id)" icon="fas fa-minus-circle text-danger" @click="removeFromAssignList(row)"
additional-class="btn-link workorder-button" title="Von Zuweisungsliste entfernen" />
<tt-button v-if="row.status === 'intervention_required'" icon="fas fa-check-circle text-success"
@click="problemSolvedModalData = row" additional-class="btn-link workorder-button" title="Auftrag auf Problem behoben setzen" />
<tt-button v-if="['intervention_required', 'assigned'].includes(row.status)" icon="fas fa-hard-hat text-orange" @click="openCivilEngineeringModal(row)"
additional-class="btn-link workorder-button" title="Tiefbau benötigt" />
<tt-button v-if="!['completed', 'cancelled'].includes(row.status)" icon="fas fa-ban text-danger" @click="cancelWorkorderModalData = row"
additional-class="btn-link workorder-button" title="Auftrag stornieren" />
<tt-button v-if="row.status === 'completed'" icon="fas fa-euro-sign text-purple" @click="chargeWorkorder(row)"
additional-class="btn-link workorder-button" title="Auftrag verrechnen" />
</div>
</div>
</template>
<template v-slot:additionalinfo="{ row }">
<div v-if="editingAdditionalInfoId === row.id" class="wo-notiz-edit">
<tt-textarea v-model="tempAdditionalInfo" @keydown.esc.native="cancelEdit" :rows="editTextareaRows" 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>
<div :class="['wo-notiz-text', expandedNotes.includes(row.id) ? 'wo-notiz-expanded' : '']">{{ row.additionalInfo || '-' }}</div>
<div class="wo-notiz-actions">
<button v-if="isNoteLong(row.additionalInfo)" class="wo-notiz-toggle" @click="toggleNoteExpand(row.id)">{{ expandedNotes.includes(row.id) ? '▴ weniger' : '▾ mehr' }}</button>
<a class="wo-notiz-edit-btn" @click="startAdditionalInfoEdit(row)" title="Zusatz-Info bearbeiten"><i class="fas fa-edit"></i></a>
</div>
</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 }">
<div v-if="!row.appointmentDate && ['new', 'assigned', 'scheduled', 'correction_requested', 'problem_solved', 'civil_engineering_completed'].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 v-if="!['completed', 'cancelled', 'documented', 'charged', 'archived'].includes(row.status)"
icon="fas fa-edit" @click="openRescheduleModal(row)"
additional-class="btn-link btn-sm p-0 ml-2" title="Termin ändern"/>
<tt-button v-if="!['completed', 'cancelled', 'documented', 'charged', 'archived'].includes(row.status)"
icon="fas fa-times" @click="clearAppointment(row)"
additional-class="btn-link btn-sm p-0 ml-1 text-danger" title="Termin löschen"/>
</div>
<span v-else></span>
</template>
<template v-slot:expandedRow="{ row }">
<civil-engineering-manager v-if="row.status === 'civil_engineering_required'" :workorder-id="row.id" :is-admin="true" class="mb-3"/>
<workorder-details-manager :workorder-id="row.id" :is-admin="true"
@workorder-completed="$refs.table.$refs.table.refreshTable()"
@accept-documentation="acceptDocumentation(row.id)"/>
</template>
</tt-table-crud>
<tt-modal v-if="civilEngineeringData" :show.sync="civilEngineeringData" title="Tiefbau zuweisen" @submit="assignCivilEngineering">
<p><strong>Auftrag:</strong> #{{ civilEngineeringData.workorder.id }}</p>
<tt-select label="Tiefbau-Firma" :options="companiesByTenant[civilEngineeringData.workorder.tenantId] || []" v-model="civilEngineeringData.companyId" sm row required/>
</tt-modal>
<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-modal v-if="problemSolvedModalData" :show.sync="problemSolvedModalData" title="Problem als gelöst markieren" @submit="setToProblemSolved">
<p>Soll das Problem bei Auftrag <strong>#{{ problemSolvedModalData.id }}</strong> als gelöst markiert werden?</p>
<tt-textarea label="Journaleintrag" v-model="problemSolvedModalData.text" sm row required/>
</tt-modal>
<tt-modal v-if="massAssignModalData" :show.sync="massAssignModalData" :title="workordersToAssign.length + ' Aufträge zuweisen'" @submit="massAssignCompanies">
<p>Sollen <strong>{{ workordersToAssign.length }}</strong> Workorder(s) der Firma <strong>{{ massAssignModalData.companyName }}</strong> zugewiesen werden?</p>
<tt-date-picker label="Deadline" v-model="massAssignModalData.deadline" :date-range="false" sm row/>
</tt-modal>
<tt-modal v-if="rescheduleModalData" :show.sync="rescheduleModalData" title="Termin verschieben" @submit="rescheduleAppointment">
<p><strong>Auftrag:</strong> #{{ rescheduleModalData.workorder.id }}</p>
<tt-date-picker label="Neuer Termin" :date-range="false" v-model="rescheduleModalData.newDate"
:additional-props="{ timePicker: true, timePicker24Hour: true, locale: { format: 'DD.MM.YYYY HH:mm' }, singleDatePicker: true }" sm row/>
<tt-textarea label="Grund" v-model="rescheduleModalData.reason" sm row required/>
</tt-modal>
</tt-card>
`,
data() {
return {
window, workordersToAssign: [], editingWorkorderId: null, editingDeadlineId: null, editingAdditionalInfoId: null,
civilEngineeringData: null, tempAdditionalInfo: '', expandedNotes: [], companiesByTenant: {}, companiesLoading: false, massAssignCompanyId: null,
cancelWorkorderModalData: null, problemSolvedModalData: null, massAssignModalData: null, rescheduleModalData: null,
crudConfig: {
...window.TT_CONFIG.CRUD_CONFIG, selectable: false, expandable: true,
customRowClass: (row) => {
if (['completed', 'new', 'cancelled', 'charged', 'archived'].includes(row.status)) return 'tt-rml-workorder-irrelevant';
if (['correction_requested', 'intervention_required'].includes(row.status)) return 'tt-rml-workorder-high';
const deadlineDate = moment.unix(row.deadlineDate);
if (!deadlineDate.isValid()) return 'tt-rml-workorder-irrelevant';
const daysLeft = deadlineDate.diff(moment(), 'days');
if (daysLeft <= 7) return 'tt-rml-workorder-urgent';
if (daysLeft <= 21) return 'tt-rml-workorder-medium';
return 'tt-rml-workorder-ontrack';
},
additionalActions: [
{
key: 'charge_workorder',
title: 'Verrechnen',
class: 'fas fa-euro-sign text-purple',
condition: (row) => row.status === 'completed'
}
]
}
}
},
computed: {
companiesForMassAssign() {
if (this.workordersToAssign.length === 0) return [];
const rows = this.$refs.table?.$refs.table.rows;
if (!rows) return[];
const firstWorkorder = rows.find(r => r.id === this.workordersToAssign[0]);
if (!firstWorkorder) return [];
return this.companiesByTenant[firstWorkorder.tenantId] || [];
},
editTextareaRows() {
const lines = (this.tempAdditionalInfo || '').split('\n').length;
return String(Math.max(4, lines + 1));
},
},
methods: {
async chargeWorkorder(row) {
if (!confirm(`Soll der Auftrag #${row.id} wirklich als "verrechnet" markiert werden?`)) return;
try {
const { data } = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderAdmin/chargeWorkorder`, { workorderId: row.id });
if (data.success) {
window.notify('success', data.message);
this.$refs.table.$refs.table.refreshTable();
} else {
window.notify('error', data.message || 'Aktion fehlgeschlagen.');
}
} catch (e) {
window.notify('error', 'Ein Netzwerkfehler ist aufgetreten.');
}
},
async acceptDocumentation(workorderId) {
const {data} = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderAdmin/acceptDocumentation`, { workorderId });
if (data.success) {
window.notify('success', data.message);
this.$refs.table.$refs.table.refreshTable();
} else window.notify('error', data.message || 'Ein Fehler ist aufgetreten.');
},
openCivilEngineeringModal(row) {
this.getCompaniesForWorkorder(row);
this.civilEngineeringData = { workorder: row, companyId: null };
},
async assignCivilEngineering() {
if (!this.civilEngineeringData.companyId) return window.notify('error', 'Bitte eine Firma auswählen.');
const {data} = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderAdmin/setCivilEngineeringRequired`, {
workorderId: this.civilEngineeringData.workorder.id, companyId: this.civilEngineeringData.companyId
});
if (data.success) {
window.notify('success', data.message);
this.$refs.table.$refs.table.refreshTable();
this.civilEngineeringData = null;
} else window.notify('error', data.message || 'Zuweisung fehlgeschlagen.');
},
addToAssignList(row) { if (!this.workordersToAssign.includes(row.id)) this.workordersToAssign.push(row.id); },
removeFromAssignList(row) { this.workordersToAssign = this.workordersToAssign.filter(id => id !== row.id); },
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 getCompaniesForWorkorder(workorder) {
if (!workorder.tenantId || this.companiesByTenant[workorder.tenantId]) return;
this.companiesLoading = true;
try {
const {data} = await axios.get(`${window.TT_CONFIG.BASE_PATH}/WorkorderAdmin/getCompanies`, {params: {tenantId: workorder.tenantId}});
this.$set(this.companiesByTenant, workorder.tenantId, data);
} catch (e) {
window.notify('error', 'Firmenliste konnte nicht geladen werden.');
} finally {
this.companiesLoading = false;
}
},
async startCompanyEdit(row) {
await this.getCompaniesForWorkorder(row);
this.editingWorkorderId = row.id;
},
async assignCompany(workorder, companyId) {
if (!companyId) { this.editingWorkorderId = null; return; }
const {data} = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderAdmin/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;
},
openMassAssignModal(companyId) {
if (!companyId) return;
const companyName = this.companiesForMassAssign.find(c => c.value === companyId)?.text;
this.massAssignModalData = { companyId, companyName, deadline: null };
},
async massAssignCompanies() {
try {
const { companyId, deadline } = this.massAssignModalData;
const {data} = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderAdmin/massAssignWorkorders`, {
companyId: companyId, workorderIds: this.workordersToAssign, deadlineDate: deadline
});
if (data.success) {
window.notify('success', data.message);
this.workordersToAssign = [];
this.$refs.table.$refs.table.refreshTable();
} else window.notify('error', data.message || 'Ein Fehler ist aufgetreten.');
} catch (e) {} finally {
this.massAssignCompanyId = null;
this.massAssignModalData = null;
}
},
async updateDeadline(workorder, newDate) {
if (!newDate) { this.editingDeadlineId = null; return; }
try {
const {data} = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderAdmin/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) {} finally {
this.editingDeadlineId = null;
}
},
async setToProblemSolved() {
const { id, text } = this.problemSolvedModalData;
if (!text || !text.trim()) return window.notify('error', 'Bitte geben Sie einen Text ein.');
const {data} = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderAdmin/setToProblemSolved`, { workorderId: id, text: text });
if (data.success) {
window.notify('success', data.message);
this.$refs.table.$refs.table.refreshTable();
this.problemSolvedModalData = null;
} else window.notify('error', data.message || 'Ein Fehler ist aufgetreten.');
},
isNoteLong(text) {
if (!text) return false;
return text.split('\n').length > 4 || text.length > 180;
},
toggleNoteExpand(rowId) {
const idx = this.expandedNotes.indexOf(rowId);
if (idx === -1) this.expandedNotes.push(rowId);
else this.expandedNotes.splice(idx, 1);
},
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}/WorkorderAdmin/updateAdditionalInfo`, {
workorderId: row.id, additionalInfo: this.tempAdditionalInfo
});
if (data.success) {
window.notify('success', data.message);
row.additionalInfo = data.newInfo; // Update local data
} else window.notify('error', data.message || 'Update fehlgeschlagen.');
} catch (e) {
} finally {
this.cancelEdit();
}
},
async cancelWorkorder() {
const { id, reason } = this.cancelWorkorderModalData;
const {data} = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderAdmin/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.');
},
getCalendarType(networkOwnerName) {
if (!networkOwnerName) return '1';
const name = networkOwnerName.toLowerCase();
if (name.includes('xinon')) return '2';
if (name.includes('sbidi')) return '7';
if (name.includes('estmk')) return '3';
return '1';
},
getCampaignName(preordercampaignId) {
const col = this.crudConfig.columns.find(c => c.key === 'preordercampaign_id');
const opt = col?.table?.filterOptions?.find(o => o.value == preordercampaignId);
return opt?.text || '';
},
openCalendarWithPrefill(workorder, dateUnix, win) {
const m = window.moment.unix(dateUnix);
const zeitraum = m.hour() < 12 ? 'VM' : 'NM';
const campaignName = this.getCampaignName(workorder.preordercampaign_id);
const locationParts = [workorder.street, workorder.hausnummer];
if (workorder.stiege) locationParts.push('/' + workorder.stiege);
const location = locationParts.join(' ') + ', ' + workorder.plz + ' ' + workorder.city;
const descLines = [];
if (workorder.oaid || campaignName || workorder.city) {
descLines.push([workorder.oaid, campaignName, workorder.city].filter(Boolean).join(' - '));
}
descLines.push('Zeitraum: ' + zeitraum);
if (workorder.phone) descLines.push('Tel.: ' + workorder.phone);
if (workorder.additionalInfo) descLines.push(workorder.additionalInfo);
const calendarData = {
type: this.getCalendarType(workorder.networkOwnerName),
subject: workorder.customerCompany || workorder.customerName || '',
location: location,
cstart: m.format('DD.MM.YYYY HH:mm'),
cend: m.clone().add(90, 'minutes').format('DD.MM.YYYY HH:mm'),
description: descLines.join('<br>'),
customer_phone: workorder.phone || null,
customer_email: workorder.email || null,
calendar_user_name: 'Pusnik',
attendee_names: ['Ziga Harc'],
};
localStorage.setItem('Calendar_create', JSON.stringify(calendarData));
win.location.href = '/Calendar/View';
},
async setAppointment(workorder, date) {
if (!date) return;
if (moment.unix(date).hour() >= 23 || moment.unix(date).hour() < 1) {
this.$refs.table.$refs.table.refreshTable();
return window.notify('error', 'Bitte Uhrzeit angeben!');
}
const calWin = window.open('about:blank', '_blank');
const {data} = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderAdmin/scheduleAppointment`, {
workorderId: workorder.id, appointmentDate: date
});
if (data.success) {
window.notify('success', data.message);
this.$refs.table.$refs.table.refreshTable();
this.openCalendarWithPrefill(workorder, date, calWin);
} else {
if (calWin) calWin.close();
window.notify('error', data.message || 'Ein Fehler ist aufgetreten.');
}
},
openRescheduleModal(row) {
this.rescheduleModalData = { workorder: row, newDate: row.appointmentDate, reason: '' };
},
async rescheduleAppointment() {
const { workorder, newDate, reason } = this.rescheduleModalData;
if (!newDate || !reason) return window.notify('error', 'Bitte geben Sie ein neues Datum und einen Grund an.');
if (moment.unix(newDate).hour() >= 23 || moment.unix(newDate).hour() < 1) return window.notify('error', 'Bitte Uhrzeit angeben!');
const calWin = window.open('about:blank', '_blank');
const {data} = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderAdmin/rescheduleAppointment`, {
workorderId: workorder.id, appointmentDate: newDate, reason: reason
});
if (data.success) {
window.notify('success', data.message);
this.$refs.table.$refs.table.refreshTable();
this.openCalendarWithPrefill(workorder, newDate, calWin);
this.rescheduleModalData = null;
} else {
if (calWin) calWin.close();
window.notify('error', data.message || 'Ein Fehler ist aufgetreten.');
}
},
async clearAppointment(workorder) {
if (!confirm('Möchten Sie den Termin wirklich löschen?')) return;
const {data} = await axios.post(`${window.TT_CONFIG.BASE_PATH}/WorkorderAdmin/clearAppointment`, {
workorderId: workorder.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.');
}
},
watch: {
workordersToAssign: {
async handler(newVal) {
if (newVal.length === 0) return;
const rows = this.$refs.table?.$refs.table.rows;
if (!rows) return;
const firstWorkorder = rows.find(r => r.id === newVal[0]);
if (!firstWorkorder) return;
const firstTenantId = firstWorkorder.tenantId;
const allSameTenant = newVal.every(id => {
const wo = rows.find(r => r.id === id);
return wo && wo.tenantId === firstTenantId;
});
if (!allSameTenant) {
window.notify('error', 'Massen-Zuweisung nur für Aufträge des gleichen Mandanten möglich.');
this.workordersToAssign.pop();
return;
}
await this.getCompaniesForWorkorder(firstWorkorder);
},
deep: true
}
}
});