277 lines
16 KiB
JavaScript
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; }
|
|
}
|
|
}
|
|
});
|