Files
thetool/public/js/pages/WorkorderDashboard/WorkorderDashboard.js
2026-01-28 11:41:56 +01:00

277 lines
16 KiB
JavaScript

Vue.component('wd-kpi-card', {
props: {
title: { type: String, required: true },
value: { type: [Number, String], required: true },
icon: { type: String, default: 'fas fa-chart-bar' },
color: { type: String, default: 'primary' },
suffix: { type: String, default: '' },
subtitle: { type: String, default: '' }
},
template: `
<div class="kpi-card" :class="'kpi-card--' + color">
<div class="kpi-card__icon"><i :class="icon"></i></div>
<div class="kpi-card__content">
<div class="kpi-card__value">{{ value }}{{ suffix }}</div>
<div class="kpi-card__title">{{ title }}</div>
<div v-if="subtitle" class="kpi-card__subtitle">{{ subtitle }}</div>
</div>
</div>
`
});
Vue.component('wd-status-chart', {
props: { data: { type: Array, required: true } },
template: '<div class="chart-wrapper"><canvas ref="chart"></canvas></div>',
data() { return { chart: null }; },
mounted() { this.renderChart(); },
watch: { data: { handler() { this.renderChart(); }, deep: true } },
methods: {
renderChart() {
if (this.chart) this.chart.destroy();
if (!this.data || this.data.length === 0) return;
this.chart = new Chart(this.$refs.chart.getContext('2d'), {
type: 'doughnut',
data: {
labels: this.data.map(d => d.label),
datasets: [{ data: this.data.map(d => d.count), backgroundColor: this.data.map(d => d.color), borderWidth: 2, borderColor: '#fff' }]
},
options: {
responsive: true, maintainAspectRatio: false, cutout: '60%',
plugins: {
legend: { position: 'right', labels: { padding: 15, usePointStyle: true, font: { size: 11 } } },
tooltip: { callbacks: { label: (ctx) => `${ctx.label}: ${ctx.raw} (${((ctx.raw / ctx.dataset.data.reduce((a, b) => a + b, 0)) * 100).toFixed(1)}%)` } }
}
}
});
}
}
});
Vue.component('wd-company-chart', {
props: { data: { type: Array, required: true } },
template: '<div class="chart-wrapper chart-wrapper--large"><canvas ref="chart"></canvas></div>',
data() { return { chart: null }; },
mounted() { this.renderChart(); },
watch: { data: { handler() { this.renderChart(); }, deep: true } },
methods: {
renderChart() {
if (this.chart) this.chart.destroy();
if (!this.data || this.data.length === 0) return;
this.chart = new Chart(this.$refs.chart.getContext('2d'), {
type: 'bar',
data: {
labels: this.data.map(d => d.company),
datasets: [
{ label: 'Abgeschlossen', data: this.data.map(d => d.completed), backgroundColor: '#10b981', borderRadius: 4 },
{ label: 'In Bearbeitung', data: this.data.map(d => d.pending), backgroundColor: '#f59e0b', borderRadius: 4 },
{ label: 'Probleme', data: this.data.map(d => d.issues), backgroundColor: '#ef4444', borderRadius: 4 }
]
},
options: {
responsive: true, maintainAspectRatio: false, indexAxis: 'y',
plugins: { legend: { position: 'top', labels: { usePointStyle: true } } },
scales: { x: { stacked: true, grid: { display: false } }, y: { stacked: true, grid: { display: false } } }
}
});
}
}
});
Vue.component('wd-trends-chart', {
props: { data: { type: Array, required: true } },
template: '<div class="chart-wrapper chart-wrapper--wide"><canvas ref="chart"></canvas></div>',
data() { return { chart: null }; },
mounted() { this.renderChart(); },
watch: { data: { handler() { this.renderChart(); }, deep: true } },
methods: {
renderChart() {
if (this.chart) this.chart.destroy();
if (!this.data || this.data.length === 0) return;
this.chart = new Chart(this.$refs.chart.getContext('2d'), {
type: 'line',
data: {
labels: this.data.map(d => d.date),
datasets: [
{ label: 'Erstellt', data: this.data.map(d => d.created), borderColor: '#3b82f6', backgroundColor: 'rgba(59, 130, 246, 0.1)', fill: true, tension: 0.3, pointRadius: 4, pointHoverRadius: 6 },
{ label: 'Abgeschlossen', data: this.data.map(d => d.completed), borderColor: '#10b981', backgroundColor: 'rgba(16, 185, 129, 0.1)', fill: true, tension: 0.3, pointRadius: 4, pointHoverRadius: 6 }
]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { position: 'top', labels: { usePointStyle: true } } },
scales: {
x: { type: 'time', time: { unit: 'day', displayFormats: { day: 'DD.MM' } }, grid: { display: false } },
y: { beginAtZero: true, grid: { color: 'rgba(0,0,0,0.05)' } }
}
}
});
}
}
});
Vue.component('wd-intervention-rates', {
props: { data: { type: Array, required: true } },
template: `
<div class="intervention-rates">
<div v-for="item in data" :key="item.company" class="intervention-rate-item">
<span class="company-name" :title="item.company">{{ item.company }}</span>
<div class="intervention-rate-item__bar">
<div class="bar-fill" :style="{ width: Math.min(item.rate * 2, 100) + '%' }" :class="getRateClass(item.rate)"></div>
</div>
<span class="rate-value" :class="getRateClass(item.rate)">{{ item.rate }}%</span>
<div class="intervention-rate-item__details"><span>{{ item.total }} Ges.</span></div>
</div>
<div v-if="data.length === 0" class="no-data">Keine Daten verfügbar</div>
</div>
`,
methods: {
getRateClass(rate) { return rate >= 20 ? 'rate--danger' : rate >= 10 ? 'rate--warning' : 'rate--success'; }
}
});
Vue.component('workorder-dashboard', {
template: `
<div class="tt-scope workorder-dashboard">
<div class="filter-bar">
<div class="filter-bar__inner">
<tt-select sm label="Mandant" :value="selectedTenant" :options="[{value: '', text: 'Bitte wählen...'}, ...filterOptions.tenants]" @input="onTenantChange" />
<tt-date-picker sm label="Zeitraum" :value="dateRange" :date-range="true" :time-picker="false" @input="onDateRangeChange" />
<tt-select sm label="Firma" :value="selectedCompany" :options="[{value: '', text: 'Alle'}, ...filterOptions.companies]" @input="selectedCompany = $event; onFilterChange()" />
<tt-select sm label="Status" :value="selectedStatus" :options="[{value: '', text: 'Alle'}, ...filterOptions.statuses]" @input="selectedStatus = $event; onFilterChange()" />
<tt-select sm label="Kampagne" :value="selectedCampaign" :options="[{value: '', text: 'Alle'}, ...filterOptions.campaigns]" @input="selectedCampaign = $event; onFilterChange()" />
<button class="btn btn-primary btn-sm refresh-btn" @click="fetchDashboardData" :disabled="!selectedTenant || loading">
<i class="fas fa-sync-alt" :class="{ 'fa-spin': loading }"></i> Aktualisieren
</button>
</div>
</div>
<div v-if="loading" class="loading-overlay"><div class="spinner"></div><p>Dashboard wird geladen...</p></div>
<div v-else-if="!selectedTenant" class="empty-state">
<i class="fas fa-building"></i>
<h3>Bitte wählen Sie einen Mandanten aus</h3>
<p>Wählen Sie oben einen Mandanten, um das Dashboard anzuzeigen.</p>
</div>
<div v-else class="dashboard-content">
<div v-if="kpis" class="kpi-row">
<wd-kpi-card title="Gesamt" :value="kpis.total || 0" icon="fas fa-clipboard-list" color="primary" />
<wd-kpi-card title="Offen" :value="kpis.offen || 0" icon="fas fa-folder-open" color="warning" />
<wd-kpi-card title="Terminisiert" :value="kpis.terminisiert || 0" icon="fas fa-calendar-check" color="success" />
<wd-kpi-card title="Problemrate" :value="kpis.interventionRate || 0" suffix="%" icon="fas fa-exclamation-triangle" color="danger" :subtitle="(kpis.issues || 0) + ' Probleme'" />
<wd-kpi-card title="Ø Bearbeitungszeit" :value="kpis.avgCompletionDays || '-'" :suffix="kpis.avgCompletionDays ? ' Tage' : ''" icon="fas fa-hourglass-half" color="info" />
</div>
<div class="charts-row">
<div class="chart-card">
<h3 class="chart-card__title"><i class="fas fa-chart-pie"></i> Status-Verteilung</h3>
<wd-status-chart :data="statusDistribution" />
</div>
<div class="chart-card chart-card--wide">
<h3 class="chart-card__title"><i class="fas fa-building"></i> Firmen-Performance</h3>
<wd-company-chart :data="companyPerformance" />
</div>
</div>
<div class="chart-card chart-card--full">
<h3 class="chart-card__title"><i class="fas fa-chart-line"></i> Zeitlicher Verlauf</h3>
<wd-trends-chart :data="timeTrends" />
</div>
<div class="chart-card chart-card--full">
<h3 class="chart-card__title"><i class="fas fa-table"></i> Firma → Status → Kampagne (Detailansicht)</h3>
<div class="detail-table-wrapper">
<table class="detail-table">
<thead><tr><th>Firma</th><th>Status</th><th>Anzahl</th><th>Kampagnen-Aufschlüsselung</th></tr></thead>
<tbody>
<tr v-for="(row, index) in companyStatusCampaign" :key="index">
<td class="company-cell">{{ row.company }}</td>
<td><span class="status-badge" :class="'status-badge--' + row.status">{{ row.statusLabel }}</span></td>
<td class="count-cell">{{ row.count }}</td>
<td class="campaigns-cell"><span v-for="(c, i) in row.campaigns" :key="i" class="campaign-tag">{{ c.name }}: {{ c.count }}</span></td>
</tr>
<tr v-if="companyStatusCampaign.length === 0"><td colspan="4" class="no-data">Keine Daten verfügbar</td></tr>
</tbody>
</table>
</div>
</div>
<div class="analytics-row">
<div class="chart-card">
<h3 class="chart-card__title"><i class="fas fa-exclamation-circle"></i> Problemquote pro Firma</h3>
<wd-intervention-rates :data="interventionRates" />
</div>
<div class="chart-card">
<h3 class="chart-card__title"><i class="fas fa-exchange-alt"></i> Häufigste Status-Übergänge</h3>
<div class="transitions-list">
<div v-for="(t, index) in statusTransitions" :key="index" class="transition-item">
<span class="transition-label">{{ t.transition }}</span>
<span class="transition-count">{{ t.count }}</span>
</div>
<div v-if="statusTransitions.length === 0" class="no-data">Keine Daten verfügbar</div>
</div>
</div>
</div>
</div>
</div>
`,
data() {
return {
selectedTenant: '', dateRange: null, selectedCompany: '', selectedStatus: '', selectedCampaign: '',
filterOptions: { tenants: [], companies: [], statuses: [], campaigns: [] },
kpis: null, statusDistribution: [], companyPerformance: [], timeTrends: [],
companyStatusCampaign: [], interventionRates: [], statusTransitions: [],
loading: false
};
},
async mounted() {
const today = new Date();
today.setHours(23, 59, 59, 0);
const threeMonthsAgo = new Date(today);
threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
threeMonthsAgo.setHours(0, 0, 0, 0);
this.dateRange = { from: Math.floor(threeMonthsAgo.getTime() / 1000), to: Math.floor(today.getTime() / 1000) };
await this.fetchFilterOptions();
},
methods: {
async fetchFilterOptions() {
try {
const response = await axios.get(`${window.TT_CONFIG['BASE_URL']}/getFilterOptions`);
this.filterOptions.tenants = response.data.tenants;
this.filterOptions.companies = response.data.companies;
this.filterOptions.statuses = response.data.statuses;
} catch (error) { console.error('Error fetching filter options:', error); }
},
async onTenantChange(tenantId) {
this.selectedTenant = tenantId;
this.selectedCampaign = '';
if (tenantId) {
try {
const response = await axios.post(`${window.TT_CONFIG['BASE_URL']}/getCampaignsForTenant`, { tenantId });
this.filterOptions.campaigns = response.data;
} catch (error) { console.error('Error fetching campaigns:', error); }
await this.fetchDashboardData();
}
},
onDateRangeChange(range) { this.dateRange = range; this.onFilterChange(); },
onFilterChange() { if (this.selectedTenant) this.fetchDashboardData(); },
async fetchDashboardData() {
if (!this.selectedTenant) return;
this.loading = true;
try {
const response = await axios.post(`${window.TT_CONFIG['BASE_URL']}/getDashboardData`, {
tenantId: this.selectedTenant,
dateFrom: this.dateRange?.from || null,
dateTo: this.dateRange?.to || null,
companyIds: this.selectedCompany ? [this.selectedCompany] : [],
statuses: this.selectedStatus ? [this.selectedStatus] : [],
campaignIds: this.selectedCampaign ? [this.selectedCampaign] : []
});
this.kpis = response.data.kpis;
this.statusDistribution = response.data.statusDistribution;
this.companyPerformance = response.data.companyPerformance;
this.timeTrends = response.data.timeTrends;
this.companyStatusCampaign = response.data.companyStatusCampaign;
this.interventionRates = response.data.interventionRates;
this.statusTransitions = response.data.statusTransitions;
} catch (error) {
console.error('Error fetching dashboard data:', error);
alert('Fehler beim Laden der Dashboard-Daten.');
} finally { this.loading = false; }
}
}
});