improve and add dashboard
This commit is contained in:
135
public/js/pages/WorkorderDashboard/WorkorderDashboard.css
Normal file
135
public/js/pages/WorkorderDashboard/WorkorderDashboard.css
Normal file
@@ -0,0 +1,135 @@
|
||||
.tt-scope.workorder-dashboard {
|
||||
--wd-primary: #4f46e5; --wd-primary-light: #6366f1;
|
||||
--wd-success: #10b981; --wd-success-light: #34d399;
|
||||
--wd-warning: #f59e0b; --wd-warning-light: #fbbf24;
|
||||
--wd-danger: #ef4444; --wd-danger-light: #f87171;
|
||||
--wd-info: #06b6d4; --wd-info-light: #22d3ee;
|
||||
--wd-gray: #6b7280; --wd-gray-light: #9ca3af;
|
||||
--wd-card-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
--wd-card-shadow-hover: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
--wd-card-radius: 12px; --wd-card-radius-sm: 8px;
|
||||
padding: 0; min-height: 100vh;
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #e4e8ec 100%);
|
||||
}
|
||||
|
||||
.tt-scope.workorder-dashboard .filter-bar {
|
||||
background: white; padding: 1rem 1.5rem; border-bottom: 1px solid #e5e7eb;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); position: sticky; top: 0; z-index: 100;
|
||||
}
|
||||
.tt-scope.workorder-dashboard .filter-bar__inner { display: flex; flex-wrap: wrap; gap: 1rem; align-items: flex-end; }
|
||||
.tt-scope.workorder-dashboard .filter-bar__inner > .form-group { margin-bottom: 0; }
|
||||
.tt-scope.workorder-dashboard .filter-bar .tt-select { min-width: 200px; }
|
||||
.tt-scope.workorder-dashboard .filter-bar .tt-select:first-child { min-width: 220px; }
|
||||
.tt-scope.workorder-dashboard .filter-bar .form-group input[type="text"] { min-width: 240px; }
|
||||
.tt-scope.workorder-dashboard .filter-bar .refresh-btn { height: 31px; margin-bottom: 0; align-self: flex-end; }
|
||||
|
||||
.tt-scope.workorder-dashboard .refresh-btn {
|
||||
display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 1rem;
|
||||
background: linear-gradient(135deg, var(--wd-primary), var(--wd-primary-light));
|
||||
border: none; border-radius: var(--wd-card-radius-sm); color: white; font-weight: 500;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.tt-scope.workorder-dashboard .refresh-btn:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(79, 70, 229, 0.4); }
|
||||
.tt-scope.workorder-dashboard .refresh-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||
|
||||
.tt-scope.workorder-dashboard .dashboard-content { padding: 1.5rem; display: flex; flex-direction: column; gap: 1.5rem; }
|
||||
|
||||
.tt-scope.workorder-dashboard .kpi-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; }
|
||||
.tt-scope.workorder-dashboard .kpi-card {
|
||||
display: flex; align-items: center; gap: 1rem; padding: 1.25rem;
|
||||
border-radius: var(--wd-card-radius); box-shadow: var(--wd-card-shadow);
|
||||
color: white; transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.tt-scope.workorder-dashboard .kpi-card:hover { transform: translateY(-2px); box-shadow: var(--wd-card-shadow-hover); }
|
||||
.tt-scope.workorder-dashboard .kpi-card--primary { background: linear-gradient(135deg, var(--wd-primary), var(--wd-primary-light)); }
|
||||
.tt-scope.workorder-dashboard .kpi-card--success { background: linear-gradient(135deg, var(--wd-success), var(--wd-success-light)); }
|
||||
.tt-scope.workorder-dashboard .kpi-card--warning { background: linear-gradient(135deg, var(--wd-warning), var(--wd-warning-light)); }
|
||||
.tt-scope.workorder-dashboard .kpi-card--danger { background: linear-gradient(135deg, var(--wd-danger), var(--wd-danger-light)); }
|
||||
.tt-scope.workorder-dashboard .kpi-card--info { background: linear-gradient(135deg, var(--wd-info), var(--wd-info-light)); }
|
||||
.tt-scope.workorder-dashboard .kpi-card__icon {
|
||||
width: 48px; height: 48px; display: flex; align-items: center; justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.2); border-radius: 50%; font-size: 1.25rem;
|
||||
}
|
||||
.tt-scope.workorder-dashboard .kpi-card__content { flex: 1; }
|
||||
.tt-scope.workorder-dashboard .kpi-card__value { font-size: 1.75rem; font-weight: 700; line-height: 1.2; }
|
||||
.tt-scope.workorder-dashboard .kpi-card__title { font-size: 0.875rem; font-weight: 500; opacity: 0.9; }
|
||||
.tt-scope.workorder-dashboard .kpi-card__subtitle { font-size: 0.75rem; opacity: 0.75; margin-top: 0.25rem; }
|
||||
|
||||
.tt-scope.workorder-dashboard .charts-row { display: grid; grid-template-columns: 1fr 2fr; gap: 1.5rem; }
|
||||
.tt-scope.workorder-dashboard .analytics-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; }
|
||||
@media (max-width: 1024px) { .tt-scope.workorder-dashboard .charts-row, .tt-scope.workorder-dashboard .analytics-row { grid-template-columns: 1fr; } }
|
||||
|
||||
.tt-scope.workorder-dashboard .chart-card {
|
||||
background: white; border-radius: var(--wd-card-radius); box-shadow: var(--wd-card-shadow);
|
||||
padding: 1.5rem; transition: box-shadow 0.2s;
|
||||
}
|
||||
.tt-scope.workorder-dashboard .chart-card:hover { box-shadow: var(--wd-card-shadow-hover); }
|
||||
.tt-scope.workorder-dashboard .chart-card--wide { grid-column: span 1; }
|
||||
.tt-scope.workorder-dashboard .chart-card--full { width: 100%; }
|
||||
.tt-scope.workorder-dashboard .chart-card__title {
|
||||
display: flex; align-items: center; gap: 0.5rem; font-size: 1rem; font-weight: 600;
|
||||
color: #1f2937; margin: 0 0 1rem 0; padding-bottom: 0.75rem; border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
.tt-scope.workorder-dashboard .chart-card__title i { color: var(--wd-primary); }
|
||||
|
||||
.tt-scope.workorder-dashboard .chart-wrapper { height: 250px; position: relative; }
|
||||
.tt-scope.workorder-dashboard .chart-wrapper--large { height: 300px; }
|
||||
.tt-scope.workorder-dashboard .chart-wrapper--wide { height: 200px; }
|
||||
|
||||
.tt-scope.workorder-dashboard .detail-table-wrapper { overflow-x: auto; max-height: 400px; overflow-y: auto; }
|
||||
.tt-scope.workorder-dashboard .detail-table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
|
||||
.tt-scope.workorder-dashboard .detail-table th {
|
||||
position: sticky; top: 0; background: #f9fafb; padding: 0.75rem 1rem;
|
||||
text-align: left; font-weight: 600; color: #374151; border-bottom: 2px solid #e5e7eb;
|
||||
}
|
||||
.tt-scope.workorder-dashboard .detail-table td { padding: 0.75rem 1rem; border-bottom: 1px solid #f3f4f6; vertical-align: top; }
|
||||
.tt-scope.workorder-dashboard .detail-table tr:hover td { background: #f9fafb; }
|
||||
.tt-scope.workorder-dashboard .company-cell { font-weight: 500; color: #1f2937; }
|
||||
.tt-scope.workorder-dashboard .count-cell { font-weight: 600; color: var(--wd-primary); }
|
||||
.tt-scope.workorder-dashboard .campaigns-cell { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
||||
.tt-scope.workorder-dashboard .campaign-tag { display: inline-block; padding: 0.25rem 0.5rem; background: #f3f4f6; border-radius: 4px; font-size: 0.75rem; color: #4b5563; }
|
||||
|
||||
.tt-scope.workorder-dashboard .status-badge {
|
||||
display: inline-block; padding: 0.25rem 0.75rem; border-radius: 9999px;
|
||||
font-size: 0.75rem; font-weight: 500; text-transform: uppercase; letter-spacing: 0.025em;
|
||||
}
|
||||
.tt-scope.workorder-dashboard .status-badge--completed, .tt-scope.workorder-dashboard .status-badge--charged { background: rgba(16, 185, 129, 0.1); color: #059669; }
|
||||
.tt-scope.workorder-dashboard .status-badge--assigned, .tt-scope.workorder-dashboard .status-badge--new { background: rgba(59, 130, 246, 0.1); color: #2563eb; }
|
||||
.tt-scope.workorder-dashboard .status-badge--scheduled, .tt-scope.workorder-dashboard .status-badge--in_progress { background: rgba(245, 158, 11, 0.1); color: #d97706; }
|
||||
.tt-scope.workorder-dashboard .status-badge--intervention_required, .tt-scope.workorder-dashboard .status-badge--correction_requested { background: rgba(239, 68, 68, 0.1); color: #dc2626; }
|
||||
.tt-scope.workorder-dashboard .status-badge--documented, .tt-scope.workorder-dashboard .status-badge--problem_solved { background: rgba(20, 184, 166, 0.1); color: #0d9488; }
|
||||
.tt-scope.workorder-dashboard .status-badge--archived, .tt-scope.workorder-dashboard .status-badge--cancelled { background: rgba(107, 114, 128, 0.1); color: #4b5563; }
|
||||
|
||||
.tt-scope.workorder-dashboard .intervention-rates { display: flex; flex-direction: column; gap: 0.375rem; max-height: 300px; overflow-y: auto; }
|
||||
.tt-scope.workorder-dashboard .intervention-rate-item { display: flex; align-items: center; gap: 0.75rem; padding: 0.375rem 0.5rem; background: #f9fafb; border-radius: var(--wd-card-radius-sm); }
|
||||
.tt-scope.workorder-dashboard .intervention-rate-item .company-name { flex: 0 0 120px; font-weight: 500; font-size: 0.8125rem; color: #1f2937; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.tt-scope.workorder-dashboard .intervention-rate-item__bar { flex: 1; height: 6px; background: #e5e7eb; border-radius: 3px; overflow: hidden; min-width: 60px; }
|
||||
.tt-scope.workorder-dashboard .intervention-rate-item__bar .bar-fill { height: 100%; border-radius: 3px; transition: width 0.5s ease; }
|
||||
.tt-scope.workorder-dashboard .intervention-rate-item__bar .bar-fill.rate--success { background: var(--wd-success); }
|
||||
.tt-scope.workorder-dashboard .intervention-rate-item__bar .bar-fill.rate--warning { background: var(--wd-warning); }
|
||||
.tt-scope.workorder-dashboard .intervention-rate-item__bar .bar-fill.rate--danger { background: var(--wd-danger); }
|
||||
.tt-scope.workorder-dashboard .intervention-rate-item .rate-value { flex: 0 0 50px; font-weight: 600; font-size: 0.8125rem; text-align: right; }
|
||||
.tt-scope.workorder-dashboard .intervention-rate-item .rate-value.rate--success { color: var(--wd-success); }
|
||||
.tt-scope.workorder-dashboard .intervention-rate-item .rate-value.rate--warning { color: var(--wd-warning); }
|
||||
.tt-scope.workorder-dashboard .intervention-rate-item .rate-value.rate--danger { color: var(--wd-danger); }
|
||||
.tt-scope.workorder-dashboard .intervention-rate-item__details { flex: 0 0 auto; display: flex; gap: 0.5rem; font-size: 0.6875rem; color: var(--wd-gray); }
|
||||
|
||||
.tt-scope.workorder-dashboard .transitions-list { display: flex; flex-direction: column; gap: 0.5rem; max-height: 300px; overflow-y: auto; }
|
||||
.tt-scope.workorder-dashboard .transition-item { display: flex; justify-content: space-between; align-items: center; padding: 0.5rem 0.75rem; background: #f9fafb; border-radius: var(--wd-card-radius-sm); font-size: 0.875rem; }
|
||||
.tt-scope.workorder-dashboard .transition-label { color: #374151; }
|
||||
.tt-scope.workorder-dashboard .transition-count { font-weight: 600; color: var(--wd-primary); background: rgba(79, 70, 229, 0.1); padding: 0.25rem 0.5rem; border-radius: 4px; }
|
||||
|
||||
.tt-scope.workorder-dashboard .loading-overlay { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 4rem; color: var(--wd-gray); }
|
||||
.tt-scope.workorder-dashboard .spinner { width: 48px; height: 48px; border: 3px solid #e5e7eb; border-top-color: var(--wd-primary); border-radius: 50%; animation: spin 1s linear infinite; margin-bottom: 1rem; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.tt-scope.workorder-dashboard .empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 4rem; text-align: center; color: var(--wd-gray); }
|
||||
.tt-scope.workorder-dashboard .empty-state i { font-size: 4rem; margin-bottom: 1rem; opacity: 0.3; }
|
||||
.tt-scope.workorder-dashboard .empty-state h3 { margin: 0 0 0.5rem 0; color: #374151; }
|
||||
.tt-scope.workorder-dashboard .empty-state p { margin: 0; }
|
||||
.tt-scope.workorder-dashboard .no-data { text-align: center; padding: 2rem; color: var(--wd-gray); font-style: italic; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.tt-scope.workorder-dashboard .filter-bar__inner { flex-direction: column; align-items: stretch; }
|
||||
.tt-scope.workorder-dashboard .kpi-row { grid-template-columns: 1fr 1fr; }
|
||||
.tt-scope.workorder-dashboard .kpi-card { flex-direction: column; text-align: center; }
|
||||
}
|
||||
276
public/js/pages/WorkorderDashboard/WorkorderDashboard.js
Normal file
276
public/js/pages/WorkorderDashboard/WorkorderDashboard.js
Normal file
@@ -0,0 +1,276 @@
|
||||
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="Abgeschlossen" :value="kpis.completed || 0" icon="fas fa-check-circle" color="success" />
|
||||
<wd-kpi-card title="In Bearbeitung" :value="kpis.pending || 0" icon="fas fa-clock" color="warning" />
|
||||
<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; }
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user