init dashboard
This commit is contained in:
+88
@@ -0,0 +1,88 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { NgModule } from '@angular/core';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatDateFnsModule } from '@angular/material-date-fns-adapter';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MAT_DATE_LOCALE } from '@angular/material/core';
|
||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatPaginatorModule } from '@angular/material/paginator';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatSortModule } from '@angular/material/sort';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { de } from 'date-fns/locale';
|
||||
import { MetricSummaryComponent } from './components/metric-summary/metric-summary.component';
|
||||
import { DayviewComponent } from './dayview/dayview.component';
|
||||
import { ManagerDashboardComponent } from './manager-dashboard/manager-dashboard.component';
|
||||
import { MonthviewComponent } from './monthview/monthview.component';
|
||||
import { OverviewComponent } from './overview/overview.component';
|
||||
import { TimePerTourByCarComponent } from './time-per-tour-by-car/time-per-tour-by-car.component';
|
||||
import { TimePerTourKPIComponent } from './time-per-tour-kpi/time-per-tour-kpi.component';
|
||||
import { ViewTypeSelectorComponent } from './components/view-type-selector/view-type-selector.component';
|
||||
import { UtilNgxEchartsModule } from '../../core/components/ngx-echarts/util-ngx-echarts.module';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule.forChild([
|
||||
{
|
||||
path: '',
|
||||
component: ManagerDashboardComponent,
|
||||
children: [
|
||||
{
|
||||
path: 'overview',
|
||||
component: OverviewComponent,
|
||||
},
|
||||
{
|
||||
path: 'dayview',
|
||||
component: DayviewComponent,
|
||||
},
|
||||
{
|
||||
path: 'monthview',
|
||||
component: MonthviewComponent,
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
pathMatch: 'full',
|
||||
redirectTo: 'overview',
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
MatCardModule,
|
||||
MatIconModule,
|
||||
MatTableModule,
|
||||
MatPaginatorModule,
|
||||
MatSortModule,
|
||||
MatDateFnsModule,
|
||||
MatDatepickerModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatTabsModule,
|
||||
MatChipsModule,
|
||||
MatProgressBarModule,
|
||||
ReactiveFormsModule,
|
||||
UtilNgxEchartsModule,
|
||||
MetricSummaryComponent,
|
||||
ViewTypeSelectorComponent,
|
||||
],
|
||||
declarations: [
|
||||
ManagerDashboardComponent,
|
||||
TimePerTourKPIComponent,
|
||||
TimePerTourByCarComponent,
|
||||
OverviewComponent,
|
||||
DayviewComponent,
|
||||
MonthviewComponent,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: MAT_DATE_LOCALE,
|
||||
useValue: de,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AvicennaWebFeatureDashboardManagerModule {}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
<div
|
||||
*ngIf="echartsOptions$ | async as echartsOptions"
|
||||
dksEcharts
|
||||
[defaultHeight]="300"
|
||||
[options]="echartsOptions"
|
||||
[extentions]="echartsExtensions"
|
||||
></div>
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { AverageRevenueMetricComponent } from './average-revenue-metric.component';
|
||||
|
||||
describe('AverageRevenueMetricComponent', () => {
|
||||
let component: AverageRevenueMetricComponent;
|
||||
let fixture: ComponentFixture<AverageRevenueMetricComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AverageRevenueMetricComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(AverageRevenueMetricComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, Input, OnChanges } from '@angular/core';
|
||||
import { format } from 'date-fns';
|
||||
import { EChartsOption, LegendComponentOption } from 'echarts';
|
||||
import { BarChart, LineChart } from 'echarts/charts';
|
||||
import {
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
TooltipComponent,
|
||||
} from 'echarts/components';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { UtilNgxEchartsModule } from '../../../../core/components/ngx-echarts/util-ngx-echarts.module';
|
||||
import { TimePerTourMetric } from '../../../../core/data-access/graphql/generated/generated';
|
||||
|
||||
@Component({
|
||||
selector: 'dks-average-revenue-metric',
|
||||
standalone: true,
|
||||
imports: [CommonModule, UtilNgxEchartsModule],
|
||||
templateUrl: './average-revenue-metric.component.html',
|
||||
styleUrls: ['./average-revenue-metric.component.css'],
|
||||
})
|
||||
export class AverageRevenueMetricComponent implements OnChanges {
|
||||
@Input() metrics: TimePerTourMetric[] = [];
|
||||
@Input() labelFormat!: string;
|
||||
|
||||
echartsExtensions = [
|
||||
BarChart,
|
||||
LineChart,
|
||||
TooltipComponent,
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
];
|
||||
|
||||
echartsOptions$: BehaviorSubject<EChartsOption> = new BehaviorSubject(
|
||||
this.generateEChartsOptions(this.metrics)
|
||||
);
|
||||
|
||||
ngOnChanges(): void {
|
||||
this.echartsOptions$.next(this.generateEChartsOptions(this.metrics));
|
||||
}
|
||||
|
||||
private generateEChartsOptions(metrics: TimePerTourMetric[]): EChartsOption {
|
||||
const legendData = ['Durchschnittsumsatz', 'Index'];
|
||||
|
||||
const legendOptions: LegendComponentOption = {
|
||||
orient: 'horizontal',
|
||||
data: legendData,
|
||||
};
|
||||
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'shadow',
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
left: '15%',
|
||||
width: '80%',
|
||||
bottom: 50,
|
||||
},
|
||||
legend: legendOptions,
|
||||
xAxis: [
|
||||
{
|
||||
type: 'category',
|
||||
axisTick: {
|
||||
show: true,
|
||||
alignWithLabel: true,
|
||||
},
|
||||
axisLabel: {
|
||||
rotate: 30,
|
||||
interval: 1,
|
||||
fontSize: 10,
|
||||
},
|
||||
data: metrics.map(({ interval: { start } }) =>
|
||||
format(new Date(start), this.labelFormat)
|
||||
),
|
||||
},
|
||||
],
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
name: 'Betrag (EUR)',
|
||||
position: 'left',
|
||||
alignTicks: true,
|
||||
axisLine: {
|
||||
show: true,
|
||||
},
|
||||
minInterval: 1,
|
||||
axisLabel: {
|
||||
fontSize: 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
name: 'Index',
|
||||
position: 'right',
|
||||
alignTicks: true,
|
||||
axisLine: {
|
||||
show: true,
|
||||
},
|
||||
interval: 2,
|
||||
min: 0,
|
||||
max: 10,
|
||||
axisLabel: {
|
||||
fontSize: 10,
|
||||
},
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: 'Index',
|
||||
type: 'line',
|
||||
data: metrics.map(({ tourAmount, availableTime }) => {
|
||||
let value = 0;
|
||||
if (tourAmount <= 0) {
|
||||
value = availableTime / 0.1;
|
||||
} else {
|
||||
value = availableTime / tourAmount;
|
||||
}
|
||||
return {
|
||||
value: value.toFixed(2),
|
||||
itemStyle: {
|
||||
borderWidth: 10,
|
||||
},
|
||||
};
|
||||
}),
|
||||
symbol: 'none',
|
||||
symbolSize: 1,
|
||||
smooth: true,
|
||||
yAxisIndex: 1,
|
||||
},
|
||||
{
|
||||
name: 'Durchschnittsumsatz',
|
||||
type: 'bar',
|
||||
data: metrics.map(({ averageRevenue }) => averageRevenue.toFixed(2)),
|
||||
itemStyle: {
|
||||
borderRadius: [5, 5, 0, 0],
|
||||
},
|
||||
barWidth: 8,
|
||||
yAxisIndex: 0,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
:host {
|
||||
@apply flex justify-center items-center;
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
<div class="m-auto flex flex-col" *ngIf="metricsSummary$ | async as summary">
|
||||
<div class="flex items-center justify-end">
|
||||
<span class="text-base">{{
|
||||
summary.averageRevenue | currency : 'EUR' : 'symbol' : '1.2-2'
|
||||
}} Ø Umsatz</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-end">
|
||||
<span class="text-base">{{
|
||||
(summary.availableTime / summary.tourAmount) | number : '1.2-2'
|
||||
}} Index</span>
|
||||
</div>
|
||||
</div>
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { AverageRevenueSummaryComponent } from './average-revenue-summary.component';
|
||||
|
||||
describe('AverageRevenueSummaryComponent', () => {
|
||||
let component: AverageRevenueSummaryComponent;
|
||||
let fixture: ComponentFixture<AverageRevenueSummaryComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AverageRevenueSummaryComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(AverageRevenueSummaryComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { BehaviorSubject, map } from 'rxjs';
|
||||
import { TimePerTourMetric } from '../../../../core/data-access/graphql/generated/generated';
|
||||
import { summarizeMetrics } from '../../util/summarize-metrics';
|
||||
|
||||
@Component({
|
||||
selector: 'dks-average-revenue-summary',
|
||||
standalone: true,
|
||||
imports: [CommonModule, MatIconModule],
|
||||
templateUrl: './average-revenue-summary.component.html',
|
||||
styleUrls: ['./average-revenue-summary.component.css'],
|
||||
})
|
||||
export class AverageRevenueSummaryComponent {
|
||||
@Input() set metrics(value: TimePerTourMetric[]) {
|
||||
this._metrics$.next(value);
|
||||
}
|
||||
|
||||
private _metrics$ = new BehaviorSubject<TimePerTourMetric[]>([]);
|
||||
|
||||
metricsSummary$ = this._metrics$.pipe(
|
||||
map((metrics) => summarizeMetrics(metrics))
|
||||
);
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
<div
|
||||
*ngIf="echartsOptions$ | async as echartsOptions"
|
||||
dksEcharts
|
||||
[defaultHeight]="300"
|
||||
[options]="echartsOptions"
|
||||
[extentions]="echartsExtensions"
|
||||
></div>
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { BreakevenMetricComponent } from './breakeven-metric.component';
|
||||
|
||||
describe('BreakevenMetricComponent', () => {
|
||||
let component: BreakevenMetricComponent;
|
||||
let fixture: ComponentFixture<BreakevenMetricComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [BreakevenMetricComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(BreakevenMetricComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, Input, OnChanges } from '@angular/core';
|
||||
import { format } from 'date-fns';
|
||||
import {
|
||||
EChartsOption,
|
||||
LegendComponentOption,
|
||||
LineSeriesOption,
|
||||
} from 'echarts';
|
||||
import { BarChart, LineChart } from 'echarts/charts';
|
||||
import {
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
TooltipComponent,
|
||||
} from 'echarts/components';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { UtilNgxEchartsModule } from '../../../../core/components/ngx-echarts/util-ngx-echarts.module';
|
||||
import { TimePerTourMetric } from '../../../../core/data-access/graphql/generated/generated';
|
||||
import { MetricTimeScale } from '../../types';
|
||||
|
||||
@Component({
|
||||
selector: 'dks-breakeven-metric',
|
||||
standalone: true,
|
||||
imports: [CommonModule, UtilNgxEchartsModule],
|
||||
templateUrl: './breakeven-metric.component.html',
|
||||
styleUrls: ['./breakeven-metric.component.css'],
|
||||
})
|
||||
export class BreakevenMetricComponent implements OnChanges {
|
||||
@Input() metrics: TimePerTourMetric[] = [];
|
||||
@Input() labelFormat!: string;
|
||||
@Input() metricTimeScale!: MetricTimeScale;
|
||||
|
||||
echartsExtensions = [
|
||||
BarChart,
|
||||
LineChart,
|
||||
TooltipComponent,
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
];
|
||||
|
||||
echartsOptions$: BehaviorSubject<EChartsOption> = new BehaviorSubject(
|
||||
this.generateEChartsOptions(this.metrics)
|
||||
);
|
||||
|
||||
ngOnChanges(): void {
|
||||
this.echartsOptions$.next(this.generateEChartsOptions(this.metrics));
|
||||
}
|
||||
|
||||
private generateEChartsOptions(metrics: TimePerTourMetric[]): EChartsOption {
|
||||
const legendData = ['Deckungsbeitrag', 'Umsatz', 'Personal', 'Verbrauch'];
|
||||
const yAxisName = 'Betrag (EUR)';
|
||||
|
||||
const legendOptions: LegendComponentOption = {
|
||||
orient: 'horizontal',
|
||||
data: legendData,
|
||||
};
|
||||
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'shadow',
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
left: '15%',
|
||||
width: '80%',
|
||||
bottom: 50,
|
||||
},
|
||||
legend: legendOptions,
|
||||
xAxis: [
|
||||
{
|
||||
type: 'category',
|
||||
axisTick: {
|
||||
show: true,
|
||||
alignWithLabel: true,
|
||||
},
|
||||
axisLabel: {
|
||||
rotate: 30,
|
||||
interval: 1,
|
||||
fontSize: 10,
|
||||
},
|
||||
data: metrics.map(({ interval: { start } }) =>
|
||||
format(new Date(start), this.labelFormat)
|
||||
),
|
||||
},
|
||||
],
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
name: yAxisName,
|
||||
position: 'left',
|
||||
alignTicks: true,
|
||||
axisLine: {
|
||||
show: true,
|
||||
},
|
||||
minInterval: 1,
|
||||
axisLabel: {
|
||||
fontSize: 10,
|
||||
},
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: 'Deckungsbeitrag',
|
||||
type: 'line',
|
||||
data: metrics.map(({ breakEven }) => {
|
||||
return {
|
||||
value: breakEven.toFixed(2),
|
||||
itemStyle: {
|
||||
borderWidth: 10,
|
||||
},
|
||||
};
|
||||
}),
|
||||
symbol: 'circle',
|
||||
symbolSize: 7,
|
||||
smooth: true,
|
||||
},
|
||||
{
|
||||
name: 'Umsatz',
|
||||
type: 'bar',
|
||||
data: metrics.map(({ revenue }) => revenue.toFixed(2)),
|
||||
itemStyle: {
|
||||
borderRadius: [5, 5, 0, 0],
|
||||
},
|
||||
barWidth: 8,
|
||||
},
|
||||
{
|
||||
name: 'Personal',
|
||||
type: 'bar',
|
||||
data: metrics.map(({ personnelCost }) => personnelCost.toFixed(2)),
|
||||
itemStyle: {
|
||||
borderRadius: [5, 5, 0, 0],
|
||||
},
|
||||
barWidth: 8,
|
||||
},
|
||||
{
|
||||
name: 'Verbrauch',
|
||||
type: 'bar',
|
||||
data: metrics.map(({ consumptionCost }) =>
|
||||
consumptionCost.toFixed(2)
|
||||
),
|
||||
itemStyle: {
|
||||
borderRadius: [2.5, 2.5, 0, 0],
|
||||
},
|
||||
barWidth: 8,
|
||||
},
|
||||
...(this.metricTimeScale === MetricTimeScale.Monthview
|
||||
? ([
|
||||
{
|
||||
name: 'Vorausschau Deckungsbeitrag',
|
||||
type: 'line',
|
||||
data: metrics.map(({ breakEven, forecastRevenue }) =>
|
||||
forecastRevenue > 0
|
||||
? (breakEven + forecastRevenue).toFixed(2)
|
||||
: undefined
|
||||
),
|
||||
symbol: 'diamond',
|
||||
symbolSize: 7,
|
||||
smooth: true,
|
||||
},
|
||||
] as LineSeriesOption[])
|
||||
: []),
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
:host {
|
||||
@apply flex justify-center items-center;
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
<div class="m-auto flex flex-col" *ngIf="metricsSummary$ | async as summary">
|
||||
<div class="flex items-center justify-end">
|
||||
<span class="text-base">{{
|
||||
summary.revenue | currency : 'EUR' : 'symbol' : '1.2-2'
|
||||
}}</span>
|
||||
<div class="p-1 text-sm" title="Umsatz">
|
||||
<mat-icon>euro</mat-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-end">
|
||||
<span class="text-base">{{
|
||||
summary.personnelCost | currency : 'EUR' : 'symbol' : '1.2-2'
|
||||
}}</span>
|
||||
<div class="p-1 text-sm" title="Personalkosten">
|
||||
<mat-icon>people</mat-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-end">
|
||||
<span class="text-base">{{
|
||||
summary.consumptionCost | currency : 'EUR' : 'symbol' : '1.2-2'
|
||||
}}</span>
|
||||
<div class="p-1 text-sm" title="Verbrauch">
|
||||
<mat-icon>local_shipping</mat-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-end">
|
||||
<span class="text-base">{{
|
||||
summary.breakEven | currency : 'EUR' : 'symbol' : '1.2-2'
|
||||
}}</span>
|
||||
<div class="p-1 text-sm" title="Deckungsbeitrag">
|
||||
<mat-icon>account_balance</mat-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-end text-yellow-500">
|
||||
<span class="text-base"
|
||||
>+{{
|
||||
summary.forecastRevenue | currency : 'EUR' : 'symbol' : '1.2-2'
|
||||
}}</span
|
||||
>
|
||||
<div class="p-1 text-sm" title="Umsatz Vorausschau">
|
||||
<mat-icon>account_balance</mat-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { BreakevenSummaryComponent } from './breakeven-summary.component';
|
||||
|
||||
describe('BreakevenSummaryComponent', () => {
|
||||
let component: BreakevenSummaryComponent;
|
||||
let fixture: ComponentFixture<BreakevenSummaryComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [BreakevenSummaryComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(BreakevenSummaryComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { BehaviorSubject, map } from 'rxjs';
|
||||
import { TimePerTourMetric } from '../../../../core/data-access/graphql/generated/generated';
|
||||
import { summarizeMetrics } from '../../util/summarize-metrics';
|
||||
|
||||
@Component({
|
||||
selector: 'dks-breakeven-summary',
|
||||
standalone: true,
|
||||
imports: [CommonModule, MatIconModule],
|
||||
templateUrl: './breakeven-summary.component.html',
|
||||
styleUrls: ['./breakeven-summary.component.css'],
|
||||
})
|
||||
export class BreakevenSummaryComponent {
|
||||
@Input() set metrics(value: TimePerTourMetric[]) {
|
||||
this._metrics$.next(value);
|
||||
}
|
||||
|
||||
private _metrics$ = new BehaviorSubject<TimePerTourMetric[]>([]);
|
||||
|
||||
metricsSummary$ = this._metrics$.pipe(
|
||||
map((metrics) => summarizeMetrics(metrics))
|
||||
);
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
<div class="grid grid-cols-1 md:grid-cols-4" *ngIf="vm$ | async as vm">
|
||||
<ng-container [ngSwitch]="selectedMetric">
|
||||
<ng-container *ngSwitchCase="DiagrammType.TimePerTour">
|
||||
<dks-time-per-tour-metric
|
||||
class="col-span-3"
|
||||
[labelFormat]="labelFormat"
|
||||
[metrics]="vm.metrics"
|
||||
[metricTimeScale]="metricTimeScale"
|
||||
[showCategories]="showCategories"
|
||||
></dks-time-per-tour-metric>
|
||||
<div class="flex flex-col justify-center items-center">
|
||||
<mat-slide-toggle (toggleChange)="showCategories = !showCategories"
|
||||
>Zeige Kategorien</mat-slide-toggle
|
||||
>
|
||||
<dks-time-per-tour-summary
|
||||
class="m-auto"
|
||||
[metrics]="vm.metrics"
|
||||
></dks-time-per-tour-summary>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-container *ngSwitchCase="DiagrammType.BreakEven">
|
||||
<dks-breakeven-metric
|
||||
class="col-span-3"
|
||||
[labelFormat]="getLabelFormatForMetricTimeScale(metricTimeScale)"
|
||||
[metrics]="vm.metrics"
|
||||
[metricTimeScale]="metricTimeScale"
|
||||
></dks-breakeven-metric>
|
||||
<dks-breakeven-summary [metrics]="vm.metrics"></dks-breakeven-summary>
|
||||
</ng-container>
|
||||
<ng-container *ngSwitchCase="DiagrammType.AverageRevenue">
|
||||
<dks-average-revenue-metric
|
||||
class="col-span-3"
|
||||
[labelFormat]="getLabelFormatForMetricTimeScale(metricTimeScale)"
|
||||
[metrics]="vm.metrics"
|
||||
></dks-average-revenue-metric>
|
||||
<dks-average-revenue-summary
|
||||
[metrics]="vm.metrics"
|
||||
></dks-average-revenue-summary>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</div>
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { MetricSummaryComponent } from './metric-summary.component';
|
||||
|
||||
describe('MetricSummaryComponent', () => {
|
||||
let component: MetricSummaryComponent;
|
||||
let fixture: ComponentFixture<MetricSummaryComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [MetricSummaryComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(MetricSummaryComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||
import { BehaviorSubject, map } from 'rxjs';
|
||||
import { TimePerTourMetric } from '../../../../core/data-access/graphql/generated/generated';
|
||||
import { DiagrammType, MetricTimeScale } from '../../types';
|
||||
import { AverageRevenueMetricComponent } from '../average-revenue-metric/average-revenue-metric.component';
|
||||
import { AverageRevenueSummaryComponent } from '../average-revenue-summary/average-revenue-summary.component';
|
||||
import { BreakevenMetricComponent } from '../breakeven-metric/breakeven-metric.component';
|
||||
import { BreakevenSummaryComponent } from '../breakeven-summary/breakeven-summary.component';
|
||||
import { TimePerTourMetricComponent } from '../time-per-tour-metric/time-per-tour-metric.component';
|
||||
import { TimePerTourSummaryComponent } from '../time-per-tour-summary/time-per-tour-summary.component';
|
||||
|
||||
@Component({
|
||||
selector: 'dks-metric-summary',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
AverageRevenueMetricComponent,
|
||||
AverageRevenueSummaryComponent,
|
||||
BreakevenMetricComponent,
|
||||
BreakevenSummaryComponent,
|
||||
TimePerTourMetricComponent,
|
||||
TimePerTourSummaryComponent,
|
||||
MatSlideToggleModule,
|
||||
],
|
||||
templateUrl: './metric-summary.component.html',
|
||||
styleUrls: ['./metric-summary.component.css'],
|
||||
})
|
||||
export class MetricSummaryComponent {
|
||||
@Input() set metrics(metrics: TimePerTourMetric[]) {
|
||||
this._metrics$.next(metrics);
|
||||
}
|
||||
@Input() selectedMetric: DiagrammType = DiagrammType.TimePerTour;
|
||||
@Input() metricTimeScale!: MetricTimeScale;
|
||||
@Input() labelFormat!: string;
|
||||
|
||||
DiagrammType = DiagrammType;
|
||||
_metrics$ = new BehaviorSubject<TimePerTourMetric[]>([]);
|
||||
showCategories = false;
|
||||
|
||||
vm$ = this._metrics$.pipe(
|
||||
map((metrics) => ({
|
||||
metrics,
|
||||
}))
|
||||
);
|
||||
|
||||
getLabelFormatForMetricTimeScale(timeScale: MetricTimeScale): string {
|
||||
if (timeScale === MetricTimeScale.Dayview) {
|
||||
return 'HH:mm';
|
||||
} else if (timeScale === MetricTimeScale.Monthview) {
|
||||
return 'd.M.yy';
|
||||
} else {
|
||||
throw new Error(
|
||||
'No label format for time scale defined, TimeScale: ' + timeScale
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
<div
|
||||
*ngIf="echartsOptions$ | async as echartsOptions"
|
||||
dksEcharts
|
||||
[defaultHeight]="300"
|
||||
[options]="echartsOptions"
|
||||
[extentions]="echartsExtensions"
|
||||
></div>
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { TimePerTourMetricComponent } from './time-per-tour-metric.component';
|
||||
|
||||
describe('TimePerTourMetricComponent', () => {
|
||||
let component: TimePerTourMetricComponent;
|
||||
let fixture: ComponentFixture<TimePerTourMetricComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [TimePerTourMetricComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(TimePerTourMetricComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, Input, OnChanges } from '@angular/core';
|
||||
import { format } from 'date-fns';
|
||||
import { BarSeriesOption, EChartsOption, LegendComponentOption } from 'echarts';
|
||||
import { BarChart, LineChart } from 'echarts/charts';
|
||||
import {
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
TooltipComponent,
|
||||
} from 'echarts/components';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { UtilNgxEchartsModule } from '../../../../core/components/ngx-echarts/util-ngx-echarts.module';
|
||||
import { TimePerTourMetric } from '../../../../core/data-access/graphql/generated/generated';
|
||||
import { MetricTimeScale } from '../../types';
|
||||
|
||||
@Component({
|
||||
selector: 'dks-time-per-tour-metric',
|
||||
standalone: true,
|
||||
imports: [CommonModule, UtilNgxEchartsModule],
|
||||
templateUrl: './time-per-tour-metric.component.html',
|
||||
styleUrls: ['./time-per-tour-metric.component.css'],
|
||||
})
|
||||
export class TimePerTourMetricComponent implements OnChanges {
|
||||
@Input() metrics: TimePerTourMetric[] = [];
|
||||
@Input() labelFormat!: string;
|
||||
@Input() showCategories!: boolean;
|
||||
@Input() metricTimeScale!: MetricTimeScale;
|
||||
|
||||
echartsExtensions = [
|
||||
BarChart,
|
||||
LineChart,
|
||||
TooltipComponent,
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
];
|
||||
|
||||
echartsOptions$: BehaviorSubject<EChartsOption> = new BehaviorSubject(
|
||||
this.generateEChartsOptions(this.metrics)
|
||||
);
|
||||
|
||||
ngOnChanges(): void {
|
||||
this.echartsOptions$.next(this.generateEChartsOptions(this.metrics));
|
||||
}
|
||||
|
||||
private generateEChartsOptions(metrics: TimePerTourMetric[]): EChartsOption {
|
||||
const categories = [
|
||||
...new Set(
|
||||
metrics
|
||||
.flatMap(({ categoryAmounts }) => categoryAmounts)
|
||||
.map(({ category }) =>
|
||||
category === 'null' ? 'Ohne Angabe' : category
|
||||
)
|
||||
),
|
||||
];
|
||||
const legendData = [
|
||||
...(this.showCategories ? categories : ['Touren']),
|
||||
'Kapazität',
|
||||
];
|
||||
const yAxisName = 'Kapazität pro Tour';
|
||||
|
||||
const legendOptions: LegendComponentOption = {
|
||||
orient: 'horizontal',
|
||||
type: 'scroll',
|
||||
data: legendData,
|
||||
};
|
||||
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'shadow',
|
||||
},
|
||||
},
|
||||
grid: {
|
||||
left: '15%',
|
||||
width: '80%',
|
||||
bottom: 50,
|
||||
},
|
||||
legend: legendOptions,
|
||||
xAxis: [
|
||||
{
|
||||
type: 'category',
|
||||
axisTick: {
|
||||
show: true,
|
||||
alignWithLabel: true,
|
||||
},
|
||||
axisLabel: {
|
||||
rotate: 30,
|
||||
interval: 1,
|
||||
fontSize: 10,
|
||||
},
|
||||
data: metrics.map(({ interval: { start } }) =>
|
||||
format(new Date(start), this.labelFormat)
|
||||
),
|
||||
},
|
||||
],
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
name: yAxisName,
|
||||
position: 'left',
|
||||
alignTicks: true,
|
||||
axisLine: {
|
||||
show: true,
|
||||
},
|
||||
minInterval: 1,
|
||||
axisLabel: {
|
||||
fontSize: 10,
|
||||
},
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: 'Kapazität',
|
||||
type: 'line',
|
||||
data: metrics.map(({ availableTime }) => availableTime),
|
||||
smooth: true,
|
||||
markPoint: {
|
||||
symbol: 'diamond',
|
||||
symbolSize: 10,
|
||||
data: [
|
||||
{
|
||||
name: 'Min',
|
||||
coord: ['13.5.23', 90],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
...(this.showCategories
|
||||
? categories.map(
|
||||
(category): BarSeriesOption => ({
|
||||
name: category,
|
||||
type: 'bar',
|
||||
stack: 'tourAmount',
|
||||
data: metrics.map(
|
||||
({ categoryAmounts }) =>
|
||||
categoryAmounts.find(({ category: c }) =>
|
||||
category === 'Ohne Angabe' ? !c : c === category
|
||||
)?.amount ?? 0
|
||||
),
|
||||
barWidth: 8,
|
||||
})
|
||||
)
|
||||
: ([
|
||||
{
|
||||
name: 'Touren',
|
||||
type: 'bar',
|
||||
stack: 'tourAmount',
|
||||
data: metrics.map(({ tourAmount, forecast }) => ({
|
||||
value: tourAmount,
|
||||
itemStyle: {
|
||||
...(forecast > 0 &&
|
||||
this.metricTimeScale === MetricTimeScale.Monthview
|
||||
? {
|
||||
borderRadius: [0, 0, 0, 0],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
})),
|
||||
itemStyle: {
|
||||
borderRadius: [5, 5, 0, 0],
|
||||
},
|
||||
barWidth: 8,
|
||||
},
|
||||
] as BarSeriesOption[])),
|
||||
// TODO: Add feature toggle for forecast
|
||||
// ...(this.metricTimeScale === MetricTimeScale.Monthview
|
||||
// ? ([
|
||||
// {
|
||||
// name: 'Vorausschau',
|
||||
// type: 'bar',
|
||||
// stack: 'tourAmount',
|
||||
// data: metrics.map(({ forecast }) => forecast.toFixed(2)),
|
||||
// itemStyle: {
|
||||
// borderRadius: [5, 5, 0, 0],
|
||||
// },
|
||||
// barWidth: 8,
|
||||
// },
|
||||
// ] as BarSeriesOption[])
|
||||
// : []),
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
:host {
|
||||
@apply flex justify-center items-center;
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
<div class="m-auto flex flex-col" *ngIf="metricsSummary$ | async as summary">
|
||||
<div class="flex items-center justify-end">
|
||||
<span class="text-base">{{ summary.tourAmount | number : '1.2-2' }}</span>
|
||||
<div class="p-1 text-sm" title="Menge Touren">
|
||||
<mat-icon>local_shipping</mat-icon>
|
||||
</div>
|
||||
</div>
|
||||
<!-- TODO: Add feature toggle for forecast -->
|
||||
<!-- <div class="flex items-center justify-end text-yellow-500">
|
||||
<span class="text-base">+{{ summary.forecast | number : '1.0-0' }}</span>
|
||||
<div class="p-1 text-sm" title="Vorausschau">
|
||||
<mat-icon>local_shipping</mat-icon>
|
||||
</div>
|
||||
</div> -->
|
||||
<div class="flex items-center justify-end">
|
||||
<span class="text-base"
|
||||
>{{ summary.availableTime | number : '1.2-2' }} h</span
|
||||
>
|
||||
<div class="p-1 text-sm" title="Verfügbare Zeit">
|
||||
<mat-icon>departure_board</mat-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-end">
|
||||
<span class="text-base">{{
|
||||
summary.breakEven | currency : 'EUR' : 'symbol' : '1.2-2'
|
||||
}}</span>
|
||||
<div class="p-1 text-sm" title="Deckungsbeitrag">
|
||||
<mat-icon>account_balance</mat-icon>
|
||||
</div>
|
||||
</div>
|
||||
<!-- TODO: Add feature toggle for forecast -->
|
||||
<!-- <div class="flex items-center justify-end text-yellow-500">
|
||||
<span class="text-base"
|
||||
>+{{
|
||||
summary.forecastRevenue | currency : 'EUR' : 'symbol' : '1.2-2'
|
||||
}}</span
|
||||
>
|
||||
<div class="p-1 text-sm" title="Vorausschau">
|
||||
<mat-icon>account_balance</mat-icon>
|
||||
</div>
|
||||
</div> -->
|
||||
</div>
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { TimePerTourSummaryComponent } from './time-per-tour-summary.component';
|
||||
|
||||
describe('TimePerTourSummaryComponent', () => {
|
||||
let component: TimePerTourSummaryComponent;
|
||||
let fixture: ComponentFixture<TimePerTourSummaryComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [TimePerTourSummaryComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(TimePerTourSummaryComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { BehaviorSubject, map } from 'rxjs';
|
||||
import { summarizeMetrics } from '../../util/summarize-metrics';
|
||||
import { TimePerTourMetric } from '../../../../core/data-access/graphql/generated/generated';
|
||||
|
||||
@Component({
|
||||
selector: 'dks-time-per-tour-summary',
|
||||
standalone: true,
|
||||
imports: [CommonModule, MatIconModule],
|
||||
templateUrl: './time-per-tour-summary.component.html',
|
||||
styleUrls: ['./time-per-tour-summary.component.css'],
|
||||
})
|
||||
export class TimePerTourSummaryComponent {
|
||||
@Input() set metrics(value: TimePerTourMetric[]) {
|
||||
this._metrics$.next(value);
|
||||
}
|
||||
|
||||
private _metrics$ = new BehaviorSubject<TimePerTourMetric[]>([]);
|
||||
|
||||
metricsSummary$ = this._metrics$.pipe(
|
||||
map((metrics) => summarizeMetrics(metrics))
|
||||
);
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
<div
|
||||
class="py-5 w-full flex flex-col justify-center items-center bg-white shadow rounded cursor-pointer transition ease-in-out hover:shadow-md hover:scale-105 duration-200"
|
||||
[ngClass]="{
|
||||
'border border-blue-400': selected
|
||||
}"
|
||||
(click)="setSelectedViewType()"
|
||||
*ngIf="metricSummary$ | async as summary"
|
||||
>
|
||||
<span class="text-lg pb-4">{{ viewType }}</span>
|
||||
|
||||
<ng-container [ngSwitch]="currentMetric">
|
||||
<div
|
||||
class="flex flex-col items-end"
|
||||
*ngSwitchCase="DiagrammType.TimePerTour"
|
||||
>
|
||||
<div class="flex items-center pb-2" title="Touren Menge">
|
||||
{{ summary.tourAmount.toFixed(2) }}
|
||||
<mat-icon class="mx-2">local_shipping</mat-icon>
|
||||
</div>
|
||||
<div class="flex items-center" title="Verfügbare Zeit">
|
||||
{{ summary.availableTime.toFixed(2) }}
|
||||
<mat-icon class="mx-2">departure_board</mat-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col items-end" *ngSwitchCase="DiagrammType.BreakEven">
|
||||
<div class="flex items-center pb-2" title="Deckungsbeitrag">
|
||||
{{ summary.breakEven | currency:'EUR' }}
|
||||
<mat-icon class="mx-2">account_balance</mat-icon>
|
||||
</div>
|
||||
<div class="flex items-center text-yellow-500" title="Erwarteter Deckungsbeitrag">
|
||||
{{ summary.forecastRevenue | currency:'EUR' }}
|
||||
<mat-icon class="mx-2">account_balance</mat-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col items-end"
|
||||
*ngSwitchCase="DiagrammType.AverageRevenue"
|
||||
>
|
||||
<div class="flex items-center pb-2" title="Durchschnittlicher Umsatz">
|
||||
<span class="mr-1">Ø</span>
|
||||
{{ summary.averageRevenue | currency:'EUR' }}
|
||||
</div>
|
||||
<div class="flex items-center" title="Zeit pro Tour Index">
|
||||
{{ calculateIndex(summary).toFixed(2) }}
|
||||
<span class="mx-2">Index</span>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ViewTypeSelectorComponent } from './view-type-selector.component';
|
||||
|
||||
describe('ViewTypeSelectorComponent', () => {
|
||||
let component: ViewTypeSelectorComponent;
|
||||
let fixture: ComponentFixture<ViewTypeSelectorComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ViewTypeSelectorComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ViewTypeSelectorComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
AsyncPipe,
|
||||
CurrencyPipe,
|
||||
NgClass,
|
||||
NgIf,
|
||||
NgSwitch,
|
||||
NgSwitchCase,
|
||||
} from '@angular/common';
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { BehaviorSubject, map } from 'rxjs';
|
||||
import { DashboardViewType, DiagrammType } from '../../types';
|
||||
import { summarizeMetrics } from '../../util/summarize-metrics';
|
||||
import { TimePerTourMetric } from '../../../../core/data-access/graphql/generated/generated';
|
||||
|
||||
@Component({
|
||||
selector: 'dks-view-type-selector',
|
||||
standalone: true,
|
||||
imports: [NgIf, NgSwitch, NgSwitchCase, NgClass, AsyncPipe, CurrencyPipe, MatIconModule],
|
||||
templateUrl: './view-type-selector.component.html',
|
||||
styleUrls: ['./view-type-selector.component.css'],
|
||||
})
|
||||
export class ViewTypeSelectorComponent {
|
||||
@Input() viewType!: DashboardViewType;
|
||||
@Input() currentMetric!: DiagrammType;
|
||||
@Input() selected = false;
|
||||
@Input() set metrics(metrics: TimePerTourMetric[]) {
|
||||
this.metrics$.next(metrics);
|
||||
}
|
||||
@Output() viewTypeSelected = new EventEmitter<void>();
|
||||
|
||||
private metrics$ = new BehaviorSubject<TimePerTourMetric[]>([]);
|
||||
metricSummary$ = this.metrics$.pipe(
|
||||
map((metrics) => summarizeMetrics(metrics))
|
||||
);
|
||||
DiagrammType = DiagrammType;
|
||||
|
||||
setSelectedViewType(): void {
|
||||
this.viewTypeSelected.emit();
|
||||
}
|
||||
|
||||
calculateIndex(metric: TimePerTourMetric): number {
|
||||
return Math.floor((metric.availableTime / metric.tourAmount) * 100) / 100;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
<ng-container *ngIf="vm$ | async as vm; else loadingTmpl">
|
||||
<div class="flex flex-col m-0 md:m-4" *ngIf="!vm.isLoading; else loadingTmpl">
|
||||
<!-- Top row with user input elements -->
|
||||
<div class="flex flex-wrap md:flex-nowrap justify-between items-center">
|
||||
<div class="flex items-center m-2">
|
||||
<button mat-icon-button (click)="addDayToSelectedDate(-1)">
|
||||
<mat-icon>chevron_left</mat-icon>
|
||||
</button>
|
||||
<div class="flex items-center">
|
||||
<span class="pr-2">
|
||||
{{ vm.selectedDate | date : 'dd.MM.yyyy' }}
|
||||
</span>
|
||||
<input
|
||||
[formControl]="selectedDate"
|
||||
[matDatepicker]="picker"
|
||||
[matDatepickerFilter]="notSunday"
|
||||
[min]="minSelectableDate"
|
||||
class="invisible w-0"
|
||||
/>
|
||||
<button mat-icon-button (click)="picker.open()">
|
||||
<mat-icon>today</mat-icon>
|
||||
</button>
|
||||
<mat-datepicker #picker></mat-datepicker>
|
||||
</div>
|
||||
<button mat-icon-button (click)="addDayToSelectedDate(1)">
|
||||
<mat-icon>chevron_right</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
<mat-chip-listbox (change)="changeSelectedType($event)">
|
||||
<mat-chip-option
|
||||
[value]="DiagrammType.TimePerTour"
|
||||
[selected]="vm.selectedMetric === DiagrammType.TimePerTour"
|
||||
>
|
||||
Kapazität pro Tour
|
||||
</mat-chip-option>
|
||||
<mat-chip-option
|
||||
[value]="DiagrammType.BreakEven"
|
||||
[selected]="vm.selectedMetric === DiagrammType.BreakEven"
|
||||
>
|
||||
Deckungsbeitrag
|
||||
</mat-chip-option>
|
||||
<mat-chip-option
|
||||
[value]="DiagrammType.AverageRevenue"
|
||||
[selected]="vm.selectedMetric === DiagrammType.AverageRevenue"
|
||||
>
|
||||
Durchschnittsumsatz
|
||||
</mat-chip-option>
|
||||
</mat-chip-listbox>
|
||||
</div>
|
||||
<div
|
||||
class="my-5 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-2 lg:gap-4"
|
||||
>
|
||||
<dks-view-type-selector
|
||||
[viewType]="DashboardViewType.BTW"
|
||||
[currentMetric]="vm.selectedMetric"
|
||||
[metrics]="vm.btwMetrics"
|
||||
[selected]="vm.selectedViewType === DashboardViewType.BTW"
|
||||
(viewTypeSelected)="setSelectedViewType(DashboardViewType.BTW)"
|
||||
></dks-view-type-selector>
|
||||
<dks-view-type-selector
|
||||
[viewType]="DashboardViewType.TSW"
|
||||
[currentMetric]="vm.selectedMetric"
|
||||
[metrics]="vm.tswMetrics"
|
||||
[selected]="vm.selectedViewType === DashboardViewType.TSW"
|
||||
(viewTypeSelected)="setSelectedViewType(DashboardViewType.TSW)"
|
||||
></dks-view-type-selector>
|
||||
<dks-view-type-selector
|
||||
[viewType]="DashboardViewType.KTW"
|
||||
[currentMetric]="vm.selectedMetric"
|
||||
[metrics]="vm.ktwMetrics"
|
||||
[selected]="vm.selectedViewType === DashboardViewType.KTW"
|
||||
(viewTypeSelected)="setSelectedViewType(DashboardViewType.KTW)"
|
||||
></dks-view-type-selector>
|
||||
<dks-view-type-selector
|
||||
[viewType]="DashboardViewType.Gesamt"
|
||||
[currentMetric]="vm.selectedMetric"
|
||||
[metrics]="vm.combinedMetrics"
|
||||
[selected]="vm.selectedViewType === DashboardViewType.Gesamt"
|
||||
(viewTypeSelected)="setSelectedViewType(DashboardViewType.Gesamt)"
|
||||
></dks-view-type-selector>
|
||||
</div>
|
||||
<div
|
||||
class="grid grid-cols-2 lg:grid-cols-4 gap-2 lg:gap-4"
|
||||
[ngSwitch]="vm.selectedViewType"
|
||||
>
|
||||
<div
|
||||
*ngSwitchCase="DashboardViewType.BTW"
|
||||
class="col-span-2 lg:col-span-4 w-full rounded p-2 bg-white shadow"
|
||||
>
|
||||
<dks-metric-summary
|
||||
[metrics]="vm.btwMetrics"
|
||||
[metricTimeScale]="metricTimeScale"
|
||||
[selectedMetric]="vm.selectedMetric"
|
||||
[labelFormat]="'HH:mm'"
|
||||
></dks-metric-summary>
|
||||
</div>
|
||||
<div
|
||||
*ngSwitchCase="DashboardViewType.TSW"
|
||||
class="col-span-2 lg:col-span-4 w-full rounded p-2 bg-white shadow"
|
||||
>
|
||||
<dks-metric-summary
|
||||
[metrics]="vm.tswMetrics"
|
||||
[metricTimeScale]="metricTimeScale"
|
||||
[selectedMetric]="vm.selectedMetric"
|
||||
[labelFormat]="'HH:mm'"
|
||||
></dks-metric-summary>
|
||||
</div>
|
||||
<div
|
||||
*ngSwitchCase="DashboardViewType.KTW"
|
||||
class="col-span-2 lg:col-span-4 w-full rounded p-2 bg-white shadow"
|
||||
>
|
||||
<dks-metric-summary
|
||||
[metrics]="vm.ktwMetrics"
|
||||
[metricTimeScale]="metricTimeScale"
|
||||
[selectedMetric]="vm.selectedMetric"
|
||||
[labelFormat]="'HH:mm'"
|
||||
></dks-metric-summary>
|
||||
</div>
|
||||
<div
|
||||
*ngSwitchCase="DashboardViewType.Gesamt"
|
||||
class="col-span-2 lg:col-span-4 w-full rounded p-2 bg-white shadow"
|
||||
>
|
||||
<dks-metric-summary
|
||||
[metrics]="vm.combinedMetrics"
|
||||
[metricTimeScale]="metricTimeScale"
|
||||
[selectedMetric]="vm.selectedMetric"
|
||||
[labelFormat]="'HH:mm'"
|
||||
></dks-metric-summary>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-template #loadingTmpl>
|
||||
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
|
||||
</ng-template>
|
||||
|
||||
<ng-template #viewTypeSelector let-typeAndSummary>
|
||||
{{ typeAndSummary.type }}
|
||||
</ng-template>
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { DayviewComponent } from './dayview.component';
|
||||
|
||||
describe('DayviewComponent', () => {
|
||||
let component: DayviewComponent;
|
||||
let fixture: ComponentFixture<DayviewComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [DayviewComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(DayviewComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { FormControl } from '@angular/forms';
|
||||
import { MatChipListboxChange } from '@angular/material/chips';
|
||||
import { addDays, addHours, format, isSunday } from 'date-fns';
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
map,
|
||||
startWith,
|
||||
switchMap,
|
||||
} from 'rxjs';
|
||||
import { groupBy } from '../../../core/util/group-by';
|
||||
import { ManagerDashboardService } from '../manager-dashboard.service';
|
||||
import {
|
||||
DashboardViewType,
|
||||
DiagrammType,
|
||||
MetricTimeScale,
|
||||
OrdinanceTypes,
|
||||
} from '../types';
|
||||
import { mergeMetrics } from '../util/merge-metric';
|
||||
|
||||
@Component({
|
||||
selector: 'dks-dayview',
|
||||
templateUrl: './dayview.component.html',
|
||||
styleUrls: ['./dayview.component.css'],
|
||||
})
|
||||
export class DayviewComponent {
|
||||
OrdinanceTypes = OrdinanceTypes;
|
||||
DiagrammType = DiagrammType;
|
||||
DashboardViewType = DashboardViewType;
|
||||
minSelectableDate = new Date('2023-01-01');
|
||||
metricTimeScale = MetricTimeScale.Dayview;
|
||||
selectedDate = new FormControl<Date>(new Date());
|
||||
selectedDate$ = this.selectedDate.valueChanges.pipe(
|
||||
startWith(this.selectedDate.value),
|
||||
map((date) => (date?.getHours() === 0 ? addHours(date, 12) : date))
|
||||
);
|
||||
selectedMetric$ = new BehaviorSubject<DiagrammType>(DiagrammType.TimePerTour);
|
||||
selectedViewType$ = new BehaviorSubject<DashboardViewType>(
|
||||
DashboardViewType.Gesamt
|
||||
);
|
||||
|
||||
vm$;
|
||||
|
||||
constructor(
|
||||
private readonly managerDashboardService: ManagerDashboardService
|
||||
) {
|
||||
this.vm$ = combineLatest([
|
||||
this.selectedDate$.pipe(
|
||||
switchMap((selectedDate) =>
|
||||
this.managerDashboardService.getTimePerTourMetricByOrdinanceTypeForDay(
|
||||
OrdinanceTypes.BTW,
|
||||
selectedDate ?? new Date()
|
||||
)
|
||||
)
|
||||
),
|
||||
this.selectedDate$.pipe(
|
||||
switchMap((selectedDate) =>
|
||||
this.managerDashboardService.getTimePerTourMetricByOrdinanceTypeForDay(
|
||||
OrdinanceTypes.TSW,
|
||||
selectedDate ?? new Date()
|
||||
)
|
||||
)
|
||||
),
|
||||
this.selectedDate$.pipe(
|
||||
switchMap((selectedDate) =>
|
||||
this.managerDashboardService.getTimePerTourMetricByOrdinanceTypeForDay(
|
||||
OrdinanceTypes.KTW,
|
||||
selectedDate ?? new Date()
|
||||
)
|
||||
)
|
||||
),
|
||||
this.selectedMetric$,
|
||||
this.selectedDate$,
|
||||
this.managerDashboardService.isLoadingDayview$,
|
||||
this.selectedViewType$,
|
||||
]).pipe(
|
||||
map(
|
||||
([
|
||||
btwMetrics,
|
||||
tswMetrics,
|
||||
ktwMetrics,
|
||||
selectedMetric,
|
||||
selectedDate,
|
||||
isLoading,
|
||||
selectedViewType,
|
||||
]) => ({
|
||||
btwMetrics,
|
||||
tswMetrics,
|
||||
ktwMetrics,
|
||||
selectedMetric,
|
||||
selectedDate,
|
||||
isLoading,
|
||||
selectedViewType,
|
||||
combinedMetrics: mergeMetrics(
|
||||
Object.values(
|
||||
groupBy(
|
||||
[...btwMetrics, ...tswMetrics, ...ktwMetrics],
|
||||
({ interval: { start } }) => format(new Date(start), 'HH:mm')
|
||||
)
|
||||
)
|
||||
),
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
notSunday = (date: Date | null): boolean => {
|
||||
return !isSunday(date ?? new Date());
|
||||
};
|
||||
|
||||
addDayToSelectedDate(days: number): void {
|
||||
const nextSelectedDate = addDays(
|
||||
this.selectedDate.value ?? new Date(),
|
||||
days
|
||||
);
|
||||
if (isSunday(nextSelectedDate)) {
|
||||
this.selectedDate.setValue(addDays(nextSelectedDate, days));
|
||||
} else {
|
||||
this.selectedDate.setValue(nextSelectedDate);
|
||||
}
|
||||
}
|
||||
|
||||
changeSelectedType(event: MatChipListboxChange) {
|
||||
this.selectedMetric$.next(event.value);
|
||||
}
|
||||
|
||||
setSelectedViewType(viewType: DashboardViewType) {
|
||||
this.selectedViewType$.next(viewType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HotToastService } from '@ngxpert/hot-toast';
|
||||
import {
|
||||
BehaviorSubject,
|
||||
Observable,
|
||||
catchError,
|
||||
combineLatest,
|
||||
map,
|
||||
of,
|
||||
retry,
|
||||
shareReplay,
|
||||
startWith,
|
||||
switchMap,
|
||||
tap,
|
||||
throwError,
|
||||
} from 'rxjs';
|
||||
import {
|
||||
GetTourAmountPerTimeKpiManagerGQL,
|
||||
GetTimePerTourMetricForDayGQL,
|
||||
GetTimePerTourMetricForMonthGQL,
|
||||
TimePerTourUpdatedGQL,
|
||||
GetTourAmountPerTimeKpiManagerQuery,
|
||||
GetTimePerTourMetricForDayQuery,
|
||||
GetTimePerTourMetricForMonthQuery,
|
||||
} from '../../core/data-access/graphql/generated/generated';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ManagerDashboardService {
|
||||
isLoadingDayview$ = new BehaviorSubject<boolean>(false);
|
||||
isLoadingMonthview$ = new BehaviorSubject<boolean>(false);
|
||||
timePerTourUpdate$;
|
||||
|
||||
constructor(
|
||||
private readonly getTourAmountPerTimeKPIGql: GetTourAmountPerTimeKpiManagerGQL,
|
||||
private readonly getTimePerTourMetricForDayGql: GetTimePerTourMetricForDayGQL,
|
||||
private readonly getTimePerTourMetricForMonthGql: GetTimePerTourMetricForMonthGQL,
|
||||
private readonly timePerTourUpdatedGql: TimePerTourUpdatedGQL,
|
||||
private readonly toast: HotToastService
|
||||
) {
|
||||
this.timePerTourUpdate$ = this.timePerTourUpdatedGql.subscribe().pipe(
|
||||
map((res) => res.data?.timePerTourUpdated),
|
||||
tap((value) => {
|
||||
if (value) {
|
||||
this.toast.info('Daten wurden aktualisiert', {
|
||||
id: 'update',
|
||||
});
|
||||
}
|
||||
}),
|
||||
shareReplay(1)
|
||||
);
|
||||
}
|
||||
|
||||
getTimePerTourKPIByOrdinanceTypes(
|
||||
ordinanceType: string
|
||||
): Observable<GetTourAmountPerTimeKpiManagerQuery['toursPerTimeKPI']> {
|
||||
const timePerTourKPIQuery = this.getTourAmountPerTimeKPIGql.watch({
|
||||
ordinanceType,
|
||||
});
|
||||
|
||||
const timePerTourKPIUpdate = this.timePerTourUpdate$.pipe(
|
||||
switchMap(() => timePerTourKPIQuery.refetch()),
|
||||
startWith(undefined)
|
||||
);
|
||||
return combineLatest([
|
||||
timePerTourKPIUpdate,
|
||||
timePerTourKPIQuery.valueChanges,
|
||||
]).pipe(
|
||||
catchError((error) => {
|
||||
console.error(error);
|
||||
return throwError(() => error);
|
||||
}),
|
||||
retry({ delay: 1000, count: 3 }),
|
||||
map(([updatedData, firstData]) =>
|
||||
updatedData ? updatedData : firstData
|
||||
),
|
||||
map((response) => response.data.toursPerTimeKPI)
|
||||
);
|
||||
}
|
||||
|
||||
getTimePerTourMetricByOrdinanceTypeForDay(
|
||||
ordinanceType: string,
|
||||
day: Date
|
||||
): Observable<GetTimePerTourMetricForDayQuery['timePerTourMetricForDay']> {
|
||||
const timePerTourMetricQuery = this.getTimePerTourMetricForDayGql.watch({
|
||||
ordinanceType,
|
||||
day,
|
||||
});
|
||||
|
||||
const timePerTourKPIUpdate = this.timePerTourUpdate$.pipe(
|
||||
switchMap(() => timePerTourMetricQuery.refetch()),
|
||||
startWith(undefined)
|
||||
);
|
||||
return combineLatest([
|
||||
timePerTourKPIUpdate,
|
||||
of([]).pipe(
|
||||
tap(() => this.isLoadingDayview$.next(true)),
|
||||
switchMap(() => timePerTourMetricQuery.valueChanges)
|
||||
),
|
||||
]).pipe(
|
||||
catchError((error) => {
|
||||
console.error(error);
|
||||
return throwError(() => error);
|
||||
}),
|
||||
retry({ delay: 1000, count: 3 }),
|
||||
tap(() => this.isLoadingDayview$.next(false)),
|
||||
map(([updatedData, firstData]) =>
|
||||
updatedData ? updatedData : firstData
|
||||
),
|
||||
map((response) => response.data.timePerTourMetricForDay)
|
||||
);
|
||||
}
|
||||
|
||||
getTimePerTourMetricByOrdinanceTypeForMonth(
|
||||
ordinanceType: string,
|
||||
day: Date
|
||||
): Observable<
|
||||
GetTimePerTourMetricForMonthQuery['timePerTourMetricForMonth']
|
||||
> {
|
||||
const timePerTourMetricQuery = this.getTimePerTourMetricForMonthGql.watch(
|
||||
{
|
||||
ordinanceType,
|
||||
day,
|
||||
},
|
||||
{ errorPolicy: 'all' }
|
||||
);
|
||||
|
||||
const timePerTourKPIUpdate = this.timePerTourUpdate$.pipe(
|
||||
switchMap(() => timePerTourMetricQuery.refetch()),
|
||||
startWith(undefined)
|
||||
);
|
||||
return combineLatest([
|
||||
timePerTourKPIUpdate,
|
||||
of([]).pipe(
|
||||
tap(() => this.isLoadingMonthview$.next(true)),
|
||||
switchMap(() => timePerTourMetricQuery.valueChanges)
|
||||
),
|
||||
]).pipe(
|
||||
catchError((error) => {
|
||||
console.error(error);
|
||||
return throwError(() => error);
|
||||
}),
|
||||
retry({ delay: 10000, count: 3 }),
|
||||
tap(() => this.isLoadingMonthview$.next(false)),
|
||||
map(([updatedData, firstData]) =>
|
||||
updatedData ? updatedData : firstData
|
||||
),
|
||||
map((response) => {
|
||||
if (response.error || response.errors) {
|
||||
console.error(response.error, response.errors);
|
||||
}
|
||||
return response.data.timePerTourMetricForMonth;
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
<nav mat-tab-nav-bar backgroundColor="accent" [tabPanel]="tabPanel">
|
||||
<a
|
||||
#link
|
||||
mat-tab-link
|
||||
*ngFor="let tab of tabs"
|
||||
[routerLink]="tab.route"
|
||||
[routerLinkActive]="'active'"
|
||||
[active]="link.elementRef.nativeElement.classList.contains('active')"
|
||||
>{{ tab.title }}</a
|
||||
>
|
||||
</nav>
|
||||
<mat-tab-nav-panel #tabPanel>
|
||||
<router-outlet></router-outlet>
|
||||
</mat-tab-nav-panel>
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { ManagerDashboardComponent } from './manager-dashboard.component';
|
||||
|
||||
describe('ManagerDashboardComponent', () => {
|
||||
let component: ManagerDashboardComponent;
|
||||
let fixture: ComponentFixture<ManagerDashboardComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [ManagerDashboardComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ManagerDashboardComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'dks-manager-dashboard',
|
||||
templateUrl: './manager-dashboard.component.html',
|
||||
styleUrls: ['./manager-dashboard.component.css'],
|
||||
})
|
||||
export class ManagerDashboardComponent {
|
||||
tabs: { title: string; route: string }[] = [
|
||||
{
|
||||
title: 'Übersicht',
|
||||
route: '/managers/overview',
|
||||
},
|
||||
{
|
||||
title: 'Tagesansicht',
|
||||
route: '/managers/dayview',
|
||||
},
|
||||
{
|
||||
title: 'Monatsansicht',
|
||||
route: '/managers/monthview',
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
.month-picker .mat-calendar-period-button {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.month-picker .mat-calendar-arrow {
|
||||
display: none;
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
<ng-container *ngIf="vm$ | async as vm; else loadingTmpl">
|
||||
<div class="flex flex-col m-0 md:m-4" *ngIf="!vm.isLoading; else loadingTmpl">
|
||||
<!-- Top row with user input elements -->
|
||||
<div class="flex flex-wrap md:flex-nowrap justify-between items-center">
|
||||
<div class="flex items-center m-2">
|
||||
<button mat-icon-button (click)="addMonthToSelectedDate(-1)">
|
||||
<mat-icon>chevron_left</mat-icon>
|
||||
</button>
|
||||
<div class="flex items-center">
|
||||
<span class="pr-2">
|
||||
{{ vm.selectedDate | date : 'MM.yyyy' }}
|
||||
</span>
|
||||
<input
|
||||
[formControl]="selectedDate"
|
||||
[matDatepicker]="picker"
|
||||
[matDatepickerFilter]="notSunday"
|
||||
[min]="minSelectableDate"
|
||||
class="invisible w-0"
|
||||
/>
|
||||
<button mat-icon-button (click)="picker.open()">
|
||||
<mat-icon>today</mat-icon>
|
||||
</button>
|
||||
<mat-datepicker
|
||||
#picker
|
||||
startView="multi-year"
|
||||
(monthSelected)="setMonthAndYear($event, picker)"
|
||||
class="month-picker"
|
||||
></mat-datepicker>
|
||||
</div>
|
||||
<button mat-icon-button (click)="addMonthToSelectedDate(1)">
|
||||
<mat-icon>chevron_right</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<mat-chip-listbox (change)="changeSelectedType($event)">
|
||||
<mat-chip-option
|
||||
[value]="DiagrammType.TimePerTour"
|
||||
[selected]="vm.selectedMetric === DiagrammType.TimePerTour"
|
||||
>
|
||||
Kapazität pro Tour
|
||||
</mat-chip-option>
|
||||
<mat-chip-option
|
||||
[value]="DiagrammType.BreakEven"
|
||||
[selected]="vm.selectedMetric === DiagrammType.BreakEven"
|
||||
>
|
||||
Deckungsbeitrag
|
||||
</mat-chip-option>
|
||||
<mat-chip-option
|
||||
[value]="DiagrammType.AverageRevenue"
|
||||
[selected]="vm.selectedMetric === DiagrammType.AverageRevenue"
|
||||
>
|
||||
Durchschnittsumsatz
|
||||
</mat-chip-option>
|
||||
</mat-chip-listbox>
|
||||
</div>
|
||||
<div
|
||||
class="my-5 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-2 lg:gap-4"
|
||||
>
|
||||
<dks-view-type-selector
|
||||
[viewType]="DashboardViewType.BTW"
|
||||
[currentMetric]="vm.selectedMetric"
|
||||
[metrics]="vm.btwMetrics"
|
||||
[selected]="vm.selectedViewType === DashboardViewType.BTW"
|
||||
(viewTypeSelected)="setSelectedViewType(DashboardViewType.BTW)"
|
||||
></dks-view-type-selector>
|
||||
<dks-view-type-selector
|
||||
[viewType]="DashboardViewType.TSW"
|
||||
[currentMetric]="vm.selectedMetric"
|
||||
[metrics]="vm.tswMetrics"
|
||||
[selected]="vm.selectedViewType === DashboardViewType.TSW"
|
||||
(viewTypeSelected)="setSelectedViewType(DashboardViewType.TSW)"
|
||||
></dks-view-type-selector>
|
||||
<dks-view-type-selector
|
||||
[viewType]="DashboardViewType.KTW"
|
||||
[currentMetric]="vm.selectedMetric"
|
||||
[metrics]="vm.ktwMetrics"
|
||||
[selected]="vm.selectedViewType === DashboardViewType.KTW"
|
||||
(viewTypeSelected)="setSelectedViewType(DashboardViewType.KTW)"
|
||||
></dks-view-type-selector>
|
||||
<dks-view-type-selector
|
||||
[viewType]="DashboardViewType.Gesamt"
|
||||
[currentMetric]="vm.selectedMetric"
|
||||
[metrics]="vm.combinedMetrics"
|
||||
[selected]="vm.selectedViewType === DashboardViewType.Gesamt"
|
||||
(viewTypeSelected)="setSelectedViewType(DashboardViewType.Gesamt)"
|
||||
></dks-view-type-selector>
|
||||
</div>
|
||||
<div
|
||||
class="grid grid-cols-2 lg:grid-cols-4 gap-2 lg:gap-4"
|
||||
[ngSwitch]="vm.selectedViewType"
|
||||
>
|
||||
<div
|
||||
*ngSwitchCase="DashboardViewType.BTW"
|
||||
class="col-span-2 lg:col-span-4 w-full rounded p-2 bg-white shadow"
|
||||
>
|
||||
<dks-metric-summary
|
||||
[metrics]="vm.btwMetrics"
|
||||
[metricTimeScale]="metricTimeScale"
|
||||
[selectedMetric]="vm.selectedMetric"
|
||||
[labelFormat]="'d.M.yy'"
|
||||
></dks-metric-summary>
|
||||
</div>
|
||||
<div
|
||||
*ngSwitchCase="DashboardViewType.TSW"
|
||||
class="col-span-2 lg:col-span-4 w-full rounded p-2 bg-white shadow"
|
||||
>
|
||||
<dks-metric-summary
|
||||
[metrics]="vm.tswMetrics"
|
||||
[metricTimeScale]="metricTimeScale"
|
||||
[selectedMetric]="vm.selectedMetric"
|
||||
[labelFormat]="'d.M.yy'"
|
||||
></dks-metric-summary>
|
||||
</div>
|
||||
<div
|
||||
*ngSwitchCase="DashboardViewType.KTW"
|
||||
class="col-span-2 lg:col-span-4 w-full rounded p-2 bg-white shadow"
|
||||
>
|
||||
<dks-metric-summary
|
||||
[metrics]="vm.ktwMetrics"
|
||||
[metricTimeScale]="metricTimeScale"
|
||||
[selectedMetric]="vm.selectedMetric"
|
||||
[labelFormat]="'d.M.yy'"
|
||||
></dks-metric-summary>
|
||||
</div>
|
||||
<div
|
||||
*ngSwitchCase="DashboardViewType.Gesamt"
|
||||
class="col-span-2 lg:col-span-4 w-full rounded p-2 bg-white shadow"
|
||||
>
|
||||
<dks-metric-summary
|
||||
[metrics]="vm.combinedMetrics"
|
||||
[metricTimeScale]="metricTimeScale"
|
||||
[selectedMetric]="vm.selectedMetric"
|
||||
[labelFormat]="'d.M.yy'"
|
||||
></dks-metric-summary>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-template #loadingTmpl>
|
||||
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
|
||||
</ng-template>
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { MonthviewComponent } from './monthview.component';
|
||||
|
||||
describe('MonthviewComponent', () => {
|
||||
let component: MonthviewComponent;
|
||||
let fixture: ComponentFixture<MonthviewComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [MonthviewComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(MonthviewComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { FormControl } from '@angular/forms';
|
||||
import { MatChipListboxChange } from '@angular/material/chips';
|
||||
import { MatDatepicker } from '@angular/material/datepicker';
|
||||
import { addDays, addMonths, format, isSunday, startOfMonth } from 'date-fns';
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
map,
|
||||
startWith,
|
||||
switchMap,
|
||||
} from 'rxjs';
|
||||
import { groupBy } from '../../../core/util/group-by';
|
||||
import { ManagerDashboardService } from '../manager-dashboard.service';
|
||||
import {
|
||||
DashboardViewType,
|
||||
DiagrammType,
|
||||
MetricTimeScale,
|
||||
OrdinanceTypes,
|
||||
} from '../types';
|
||||
import { mergeMetrics } from '../util/merge-metric';
|
||||
|
||||
@Component({
|
||||
selector: 'dks-monthview',
|
||||
templateUrl: './monthview.component.html',
|
||||
styleUrls: ['./monthview.component.css'],
|
||||
})
|
||||
export class MonthviewComponent {
|
||||
OrdinanceTypes = OrdinanceTypes;
|
||||
DiagrammType = DiagrammType;
|
||||
DashboardViewType = DashboardViewType;
|
||||
minSelectableDate = new Date('2022-08-01');
|
||||
metricTimeScale = MetricTimeScale.Monthview;
|
||||
selectedDate = new FormControl<Date>(addDays(startOfMonth(new Date()), 1));
|
||||
selectedDate$ = this.selectedDate.valueChanges.pipe(
|
||||
startWith(this.selectedDate.value)
|
||||
);
|
||||
selectedMetric$ = new BehaviorSubject<DiagrammType>(DiagrammType.TimePerTour);
|
||||
selectedViewType$ = new BehaviorSubject<DashboardViewType>(
|
||||
DashboardViewType.Gesamt
|
||||
);
|
||||
|
||||
vm$;
|
||||
|
||||
constructor(
|
||||
private readonly managerDashboardService: ManagerDashboardService
|
||||
) {
|
||||
this.vm$ = combineLatest([
|
||||
this.selectedDate$.pipe(
|
||||
switchMap((selectedDate) =>
|
||||
this.managerDashboardService.getTimePerTourMetricByOrdinanceTypeForMonth(
|
||||
OrdinanceTypes.BTW,
|
||||
selectedDate ?? new Date()
|
||||
)
|
||||
)
|
||||
),
|
||||
this.selectedDate$.pipe(
|
||||
switchMap((selectedDate) =>
|
||||
this.managerDashboardService.getTimePerTourMetricByOrdinanceTypeForMonth(
|
||||
OrdinanceTypes.TSW,
|
||||
selectedDate ?? new Date()
|
||||
)
|
||||
)
|
||||
),
|
||||
this.selectedDate$.pipe(
|
||||
switchMap((selectedDate) =>
|
||||
this.managerDashboardService.getTimePerTourMetricByOrdinanceTypeForMonth(
|
||||
OrdinanceTypes.KTW,
|
||||
selectedDate ?? new Date()
|
||||
)
|
||||
)
|
||||
),
|
||||
this.selectedMetric$,
|
||||
this.selectedDate$,
|
||||
this.managerDashboardService.isLoadingMonthview$,
|
||||
this.selectedViewType$,
|
||||
]).pipe(
|
||||
map(
|
||||
([
|
||||
btwMetrics,
|
||||
tswMetrics,
|
||||
ktwMetrics,
|
||||
selectedMetric,
|
||||
selectedDate,
|
||||
isLoading,
|
||||
selectedViewType,
|
||||
]) => ({
|
||||
btwMetrics,
|
||||
tswMetrics,
|
||||
ktwMetrics,
|
||||
selectedMetric,
|
||||
selectedDate,
|
||||
isLoading,
|
||||
selectedViewType,
|
||||
combinedMetrics: mergeMetrics(
|
||||
Object.values(
|
||||
groupBy(
|
||||
[...btwMetrics, ...tswMetrics, ...ktwMetrics],
|
||||
({ interval: { start } }) => format(new Date(start), 'd.M.yy')
|
||||
)
|
||||
)
|
||||
),
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
notSunday = (date: Date | null): boolean => {
|
||||
return !isSunday(date ?? new Date());
|
||||
};
|
||||
|
||||
addMonthToSelectedDate(months: number): void {
|
||||
const nextSelectedDate = addMonths(
|
||||
this.selectedDate.value ?? new Date(),
|
||||
months
|
||||
);
|
||||
if (isSunday(nextSelectedDate)) {
|
||||
this.selectedDate.setValue(addDays(nextSelectedDate, 1));
|
||||
} else {
|
||||
this.selectedDate.setValue(nextSelectedDate);
|
||||
}
|
||||
}
|
||||
|
||||
changeSelectedType(event: MatChipListboxChange) {
|
||||
this.selectedMetric$.next(event.value);
|
||||
}
|
||||
|
||||
setMonthAndYear(
|
||||
normalizedMonthAndYear: Date,
|
||||
datepicker: MatDatepicker<Date>
|
||||
) {
|
||||
this.selectedDate.setValue(addDays(normalizedMonthAndYear, 1));
|
||||
datepicker.close();
|
||||
}
|
||||
|
||||
setSelectedViewType(viewType: DashboardViewType) {
|
||||
this.selectedViewType$.next(viewType);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<div
|
||||
class="flex flex-col m-0 md:m-4"
|
||||
*ngIf="vm$ | async as vm; else loadingTmpl"
|
||||
>
|
||||
<div class="flex flex-wrap md:flex-nowrap">
|
||||
<!-- KPI -->
|
||||
<dks-time-per-tour-kpi
|
||||
class="w-full md:w-1/3 m-2 rounded cursor-pointer transition ease-in-out hover:scale-105 duration-200"
|
||||
[ngClass]="{
|
||||
'border border-blue-400':
|
||||
vm.selectedOrdinanceType === OrdinanceTypes.BTW
|
||||
}"
|
||||
[text]="OrdinanceTypes.BTW"
|
||||
[kpis]="vm.btwKPI.summaries"
|
||||
(click)="setSelectedOrdinanceType(OrdinanceTypes.BTW)"
|
||||
></dks-time-per-tour-kpi>
|
||||
<dks-time-per-tour-kpi
|
||||
class="w-full md:w-1/3 m-2 rounded cursor-pointer transition ease-in-out hover:scale-105 duration-200"
|
||||
[ngClass]="{
|
||||
'border border-blue-400':
|
||||
vm.selectedOrdinanceType === OrdinanceTypes.TSW
|
||||
}"
|
||||
[text]="OrdinanceTypes.TSW"
|
||||
[kpis]="vm.tswKPI.summaries"
|
||||
(click)="setSelectedOrdinanceType(OrdinanceTypes.TSW)"
|
||||
></dks-time-per-tour-kpi>
|
||||
<dks-time-per-tour-kpi
|
||||
class="w-full md:w-1/3 m-2 rounded cursor-pointer transition ease-in-out hover:scale-105 duration-200"
|
||||
[ngClass]="{
|
||||
'border border-blue-400':
|
||||
vm.selectedOrdinanceType === OrdinanceTypes.KTW
|
||||
}"
|
||||
[text]="OrdinanceTypes.KTW"
|
||||
[kpis]="vm.ktwKPI.summaries"
|
||||
(click)="setSelectedOrdinanceType(OrdinanceTypes.KTW)"
|
||||
></dks-time-per-tour-kpi>
|
||||
</div>
|
||||
<div
|
||||
class="md:p-2 flex flex-wrap md:flex-nowrap"
|
||||
*ngIf="vm.selectedOrdinanceType"
|
||||
>
|
||||
<!-- Data Table -->
|
||||
<ng-container [ngSwitch]="vm.selectedOrdinanceType">
|
||||
<dks-time-per-tour-by-car
|
||||
[columns]="vm.btwKPI.cars.columns"
|
||||
[data]="vm.btwKPI.cars.data"
|
||||
class="w-full"
|
||||
*ngSwitchCase="OrdinanceTypes.BTW"
|
||||
></dks-time-per-tour-by-car>
|
||||
<dks-time-per-tour-by-car
|
||||
[columns]="vm.tswKPI.cars.columns"
|
||||
[data]="vm.tswKPI.cars.data"
|
||||
class="w-full"
|
||||
*ngSwitchCase="OrdinanceTypes.TSW"
|
||||
></dks-time-per-tour-by-car>
|
||||
<dks-time-per-tour-by-car
|
||||
[columns]="vm.ktwKPI.cars.columns"
|
||||
[data]="vm.ktwKPI.cars.data"
|
||||
class="w-full"
|
||||
*ngSwitchCase="OrdinanceTypes.KTW"
|
||||
></dks-time-per-tour-by-car>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
<ng-template #loadingTmpl>
|
||||
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
|
||||
</ng-template>
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { OverviewComponent } from './overview.component';
|
||||
|
||||
describe('OverviewComponent', () => {
|
||||
let component: OverviewComponent;
|
||||
let fixture: ComponentFixture<OverviewComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [OverviewComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(OverviewComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { format, Interval } from 'date-fns';
|
||||
import { BehaviorSubject, combineLatest, map } from 'rxjs';
|
||||
import { ManagerDashboardService } from '../manager-dashboard.service';
|
||||
import { OrdinanceTypes } from '../types';
|
||||
|
||||
@Component({
|
||||
selector: 'dks-overview',
|
||||
templateUrl: './overview.component.html',
|
||||
styleUrls: ['./overview.component.css'],
|
||||
})
|
||||
export class OverviewComponent {
|
||||
OrdinanceTypes = OrdinanceTypes;
|
||||
selectedOrdinanceType$ = new BehaviorSubject<OrdinanceTypes | undefined>(
|
||||
undefined
|
||||
);
|
||||
vm$;
|
||||
|
||||
constructor(
|
||||
private readonly managerDashboardService: ManagerDashboardService
|
||||
) {
|
||||
this.vm$ = combineLatest([
|
||||
this.managerDashboardService.getTimePerTourKPIByOrdinanceTypes(
|
||||
OrdinanceTypes.BTW
|
||||
),
|
||||
this.managerDashboardService.getTimePerTourKPIByOrdinanceTypes(
|
||||
OrdinanceTypes.TSW
|
||||
),
|
||||
this.managerDashboardService.getTimePerTourKPIByOrdinanceTypes(
|
||||
OrdinanceTypes.KTW
|
||||
),
|
||||
this.selectedOrdinanceType$,
|
||||
]).pipe(
|
||||
map(([btwKPIs, tswKPIs, ktwKPIs, selectedOrdinanceType]) => ({
|
||||
btwKPI: {
|
||||
summaries: btwKPIs.map(({ interval, kpi }) => ({ interval, kpi })),
|
||||
cars: this.createCarTableData(
|
||||
btwKPIs.map(({ interval, cars }) => ({ interval, cars }))
|
||||
),
|
||||
},
|
||||
tswKPI: {
|
||||
summaries: tswKPIs.map(({ interval, kpi }) => ({ interval, kpi })),
|
||||
cars: this.createCarTableData(
|
||||
tswKPIs.map(({ interval, cars }) => ({ interval, cars }))
|
||||
),
|
||||
},
|
||||
ktwKPI: {
|
||||
summaries: ktwKPIs.map(({ interval, kpi }) => ({ interval, kpi })),
|
||||
cars: this.createCarTableData(
|
||||
ktwKPIs.map(({ interval, cars }) => ({ interval, cars }))
|
||||
),
|
||||
},
|
||||
selectedOrdinanceType,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
setSelectedOrdinanceType(ordinanceType: OrdinanceTypes): void {
|
||||
if (this.selectedOrdinanceType$.getValue() === ordinanceType) {
|
||||
this.selectedOrdinanceType$.next(undefined);
|
||||
} else {
|
||||
this.selectedOrdinanceType$.next(ordinanceType);
|
||||
}
|
||||
}
|
||||
|
||||
private createCarTableData(
|
||||
carsByInterval: {
|
||||
interval: Interval;
|
||||
cars: {
|
||||
carName: string;
|
||||
kpi: {
|
||||
value: number;
|
||||
meta: { availableTime: number; tourAmount: number };
|
||||
};
|
||||
}[];
|
||||
}[]
|
||||
) {
|
||||
const columns = [
|
||||
{ id: 'carName', title: 'Fahrzeug' },
|
||||
...carsByInterval.map(({ interval }, index) => ({
|
||||
id: `slot${index}`,
|
||||
title: `${format(new Date(interval.start), 'HH:mm')} - ${format(
|
||||
new Date(interval.end),
|
||||
'HH:mm'
|
||||
)}`,
|
||||
})),
|
||||
];
|
||||
const groupedByCars = carsByInterval.reduce(
|
||||
(grouped, carsWithInterval, index) => {
|
||||
const { cars } = carsWithInterval;
|
||||
let newGroup = { ...grouped };
|
||||
for (const car of cars) {
|
||||
newGroup = {
|
||||
...newGroup,
|
||||
[car.carName]: {
|
||||
carName: car.carName,
|
||||
...(newGroup[car.carName] ?? {}),
|
||||
[`slot${index}`]: car.kpi.value,
|
||||
additionalData: [
|
||||
...(newGroup[car.carName]?.additionalData ?? []),
|
||||
car.kpi.meta,
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
return newGroup;
|
||||
},
|
||||
{} as Record<string, any>
|
||||
);
|
||||
return { columns, data: Object.values(groupedByCars) };
|
||||
}
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
import { DataSource } from '@angular/cdk/collections';
|
||||
import { MatPaginator } from '@angular/material/paginator';
|
||||
import { MatSort } from '@angular/material/sort';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { Observable, of as observableOf, merge } from 'rxjs';
|
||||
|
||||
export interface TimePerTourByCarItem {
|
||||
name: string;
|
||||
id: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Data source for the TimePerTourByCar view. This class should
|
||||
* encapsulate all logic for fetching and manipulating the displayed data
|
||||
* (including sorting, pagination, and filtering).
|
||||
*/
|
||||
export class TimePerTourByCarDataSource extends DataSource<TimePerTourByCarItem> {
|
||||
data: TimePerTourByCarItem[] = [];
|
||||
paginator: MatPaginator | undefined;
|
||||
sort: MatSort | undefined;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect this data source to the table. The table will only update when
|
||||
* the returned stream emits new items.
|
||||
* @returns A stream of the items to be rendered.
|
||||
*/
|
||||
connect(): Observable<TimePerTourByCarItem[]> {
|
||||
if (this.paginator && this.sort) {
|
||||
// Combine everything that affects the rendered data into one update
|
||||
// stream for the data-table to consume.
|
||||
return merge(observableOf(this.data), this.paginator.page, this.sort.sortChange)
|
||||
.pipe(map(() => {
|
||||
return this.getPagedData(this.getSortedData([...this.data ]));
|
||||
}));
|
||||
} else {
|
||||
throw Error('Please set the paginator and sort on the data source before connecting.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the table is being destroyed. Use this function, to clean up
|
||||
* any open connections or free any held resources that were set up during connect.
|
||||
*/
|
||||
disconnect(): void {}
|
||||
|
||||
/**
|
||||
* Paginate the data (client-side). If you're using server-side pagination,
|
||||
* this would be replaced by requesting the appropriate data from the server.
|
||||
*/
|
||||
private getPagedData(data: TimePerTourByCarItem[]): TimePerTourByCarItem[] {
|
||||
if (this.paginator) {
|
||||
const startIndex = this.paginator.pageIndex * this.paginator.pageSize;
|
||||
return data.splice(startIndex, this.paginator.pageSize);
|
||||
} else {
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort the data (client-side). If you're using server-side sorting,
|
||||
* this would be replaced by requesting the appropriate data from the server.
|
||||
*/
|
||||
private getSortedData(data: TimePerTourByCarItem[]): TimePerTourByCarItem[] {
|
||||
if (!this.sort || !this.sort.active || this.sort.direction === '') {
|
||||
return data;
|
||||
}
|
||||
|
||||
return data.sort((a, b) => {
|
||||
const isAsc = this.sort?.direction === 'asc';
|
||||
switch (this.sort?.active) {
|
||||
case 'name': return compare(a.name, b.name, isAsc);
|
||||
case 'id': return compare(+a.id, +b.id, isAsc);
|
||||
default: return 0;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Simple sort comparator for example ID/Name columns (for client-side sorting). */
|
||||
function compare(a: string | number, b: string | number, isAsc: boolean): number {
|
||||
return (a < b ? -1 : 1) * (isAsc ? 1 : -1);
|
||||
}
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
.full-width-table {
|
||||
width: 100%;
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
<div class="mat-elevation-z2">
|
||||
<table
|
||||
mat-table
|
||||
[dataSource]="data"
|
||||
multiTemplateDataRows
|
||||
class="full-width-table"
|
||||
matSort
|
||||
aria-label="Fahrzeuge"
|
||||
>
|
||||
<ng-container matColumnDef="{{ column.id }}" *ngFor="let column of columns">
|
||||
<th mat-header-cell *matHeaderCellDef>{{ column.title }}</th>
|
||||
<td mat-cell *matCellDef="let carData" [ngClass]="{'bg-gray-50': carData.carName === 'Touren ohne Zuweisung'}">{{ carData[column.id] }}{{(column.id !=='carName' && carData.carName === 'Touren ohne Zuweisung') ? 'T' : ''}}</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="expandedDetail">
|
||||
<td mat-cell *matCellDef="let carData" [attr.colspan]="displayedColumns.length">
|
||||
<div class="overflow-hidden flex flex-col items-start pl-0 md:pl-8" [@detailExpand]="carData === expandedCarData ? 'expanded' : 'collapsed'">
|
||||
<div class="flex" *ngFor="let detail of carData.additionalData; index as idx">
|
||||
<div class="pr-2 md:px-4">
|
||||
<span class="text-base font-semibold">{{getSlotTitleByIndex(idx)}}</span>
|
||||
</div>
|
||||
<div class="px-2 md:px-4 w-20 md:w-32 flex items-center justify-end">
|
||||
<span class="pr-2 text-base">{{detail.availableTime}}</span><mat-icon title="Verfügbare Zeit">departure_board</mat-icon>
|
||||
</div>
|
||||
<div class="px-2 md:px-4 w-20 md:w-32 flex items-center justify-end">
|
||||
<span class="pr-2 text-base">{{detail.tourAmount}}</span><mat-icon title="Menge Touren">local_shipping</mat-icon>
|
||||
</div>
|
||||
<div class="px-2 md:px-4 w-20 md:w-32 flex items-center justify-end">
|
||||
<span class="pr-2 text-base">{{detail.breakEven}}€</span><mat-icon title="Deckungsbeitrag">account_balance</mat-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
|
||||
<tr
|
||||
mat-row
|
||||
*matRowDef="let carData; columns: displayedColumns"
|
||||
class="[&_td]:border-b-0"
|
||||
[ngClass]="{'hover:bg-gray-100': carData !== expandedCarData}"
|
||||
(click)="setExpandedCarData(carData)"
|
||||
></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: ['expandedDetail']" class="!h-0"></tr>
|
||||
</table>
|
||||
</div>
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
import { waitForAsync, ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
|
||||
import { MatPaginatorModule } from '@angular/material/paginator';
|
||||
import { MatSortModule } from '@angular/material/sort';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
|
||||
import { TimePerTourByCarComponent } from './time-per-tour-by-car.component';
|
||||
|
||||
describe('TimePerTourByCarComponent', () => {
|
||||
let component: TimePerTourByCarComponent;
|
||||
let fixture: ComponentFixture<TimePerTourByCarComponent>;
|
||||
|
||||
beforeEach(waitForAsync(() => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [ TimePerTourByCarComponent ],
|
||||
imports: [
|
||||
NoopAnimationsModule,
|
||||
MatPaginatorModule,
|
||||
MatSortModule,
|
||||
MatTableModule,
|
||||
]
|
||||
}).compileComponents();
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
fixture = TestBed.createComponent(TimePerTourByCarComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should compile', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
animate,
|
||||
state,
|
||||
style,
|
||||
transition,
|
||||
trigger,
|
||||
} from '@angular/animations';
|
||||
import { Component, Input, OnChanges, OnInit } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'dks-time-per-tour-by-car',
|
||||
templateUrl: './time-per-tour-by-car.component.html',
|
||||
styleUrls: ['./time-per-tour-by-car.component.css'],
|
||||
animations: [
|
||||
trigger('detailExpand', [
|
||||
state('collapsed', style({ height: '0px', minHeight: '0' })),
|
||||
state('expanded', style({ height: '*' })),
|
||||
transition(
|
||||
'expanded <=> collapsed',
|
||||
animate('225ms cubic-bezier(0.4, 0.0, 0.2, 1)')
|
||||
),
|
||||
]),
|
||||
],
|
||||
})
|
||||
export class TimePerTourByCarComponent implements OnInit, OnChanges {
|
||||
@Input() columns: { id: string; title: string }[] = [];
|
||||
@Input() data: any[] = [];
|
||||
|
||||
/** Columns displayed in the table. Columns IDs can be added, removed, or reordered. */
|
||||
displayedColumns: string[] = [];
|
||||
expandedCarData = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.displayedColumns = this.columns.map(({ id }) => id);
|
||||
}
|
||||
|
||||
ngOnChanges(): void {
|
||||
this.expandedCarData = null;
|
||||
}
|
||||
|
||||
getSlotTitleByIndex(idx: number): string {
|
||||
return this.columns.find(({ id }) => id === `slot${idx}`)?.title ?? '';
|
||||
}
|
||||
|
||||
setExpandedCarData(carData: any): void {
|
||||
if(carData.carName !== 'Touren ohne Zuweisung') {
|
||||
this.expandedCarData = this.expandedCarData === carData ? null : carData;
|
||||
}
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
<mat-card>
|
||||
<mat-card-header class="flex justify-center">
|
||||
<mat-card-title>{{ text }}</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<div class="flex">
|
||||
<div class="flex flex-col justify-end items-center">
|
||||
<div class="p-1 text-sm" title="Verfügbare Zeit"><mat-icon>departure_board</mat-icon></div>
|
||||
<div class="p-1 text-sm" title="Menge Touren"><mat-icon>local_shipping</mat-icon></div>
|
||||
<div class="p-1 text-sm" title="Deckungsbeitrag"><mat-icon>account_balance</mat-icon></div>
|
||||
</div>
|
||||
<div
|
||||
*ngFor="let kpi of kpis; index as idx"
|
||||
class="w-1/3 flex flex-col justify-center items-center"
|
||||
>
|
||||
<div
|
||||
class="font-bold border-blue-400"
|
||||
[ngClass]="{ 'border-b-2': (currentTimeSlot$ | async) === idx }"
|
||||
>
|
||||
{{ kpi.interval.start | date: 'HH:mm' }} -
|
||||
{{ kpi.interval.end | date: 'HH:mm' }}
|
||||
</div>
|
||||
<div class="p-4 text-base">
|
||||
{{ kpi.kpi.value.toString().replace('.', ',') }}
|
||||
</div>
|
||||
<div class="p-2 flex justify-center items-center text-sm">
|
||||
{{ kpi.kpi.meta.availableTime }}
|
||||
</div>
|
||||
<div class="p-2 flex justify-center items-center text-sm">
|
||||
{{ kpi.kpi.meta.tourAmount }}
|
||||
</div>
|
||||
<div class="p-2 flex justify-center items-center text-sm">
|
||||
{{ kpi.kpi.meta.breakEven}}€
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { TimePerTourKPIComponent } from './time-per-tour-kpi.component';
|
||||
|
||||
describe('TimePerTourKPIComponent', () => {
|
||||
let component: TimePerTourKPIComponent;
|
||||
let fixture: ComponentFixture<TimePerTourKPIComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [TimePerTourKPIComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(TimePerTourKPIComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { Component, Input } from '@angular/core';
|
||||
import { Interval, isWithinInterval } from 'date-fns';
|
||||
import { interval, map, startWith } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
selector: 'dks-time-per-tour-kpi',
|
||||
templateUrl: './time-per-tour-kpi.component.html',
|
||||
styleUrls: ['./time-per-tour-kpi.component.css'],
|
||||
})
|
||||
export class TimePerTourKPIComponent {
|
||||
@Input() text = '';
|
||||
@Input() kpis: {
|
||||
interval: Interval;
|
||||
kpi: {
|
||||
value: number;
|
||||
meta: {
|
||||
availableTime: number;
|
||||
tourAmount: number;
|
||||
breakEven: number;
|
||||
};
|
||||
};
|
||||
}[] = [];
|
||||
|
||||
currentTimeSlot$ = interval(60000).pipe(
|
||||
startWith(0),
|
||||
map(() =>
|
||||
this.kpis.findIndex((kpi) =>
|
||||
isWithinInterval(new Date(), {
|
||||
start: new Date(kpi.interval.start),
|
||||
end: new Date(kpi.interval.end),
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export enum OrdinanceTypes {
|
||||
BTW = 'BTW',
|
||||
TSW = 'TSW',
|
||||
KTW = 'KTW',
|
||||
}
|
||||
|
||||
export enum DiagrammType {
|
||||
TimePerTour = 'TimePerTour',
|
||||
BreakEven = 'BreakEven',
|
||||
AverageRevenue = 'AverageRevenue',
|
||||
}
|
||||
|
||||
export enum DashboardViewType {
|
||||
BTW = 'BTW',
|
||||
TSW = 'TSW',
|
||||
KTW = 'KTW',
|
||||
Gesamt = 'Gesamt',
|
||||
}
|
||||
|
||||
export enum MetricTimeScale {
|
||||
Dayview = 'Dayview',
|
||||
Monthview = 'Monthview',
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { TimePerTourMetric, TimePerTourMetricCategoryAmount } from '../../../core/data-access/graphql/generated/generated';
|
||||
import { groupBy } from '../../../core/util/group-by';
|
||||
|
||||
export function mergeMetrics(
|
||||
metrics: TimePerTourMetric[][]
|
||||
): TimePerTourMetric[] {
|
||||
return metrics.reduce(
|
||||
(metrics, metricsByLabel) => [
|
||||
...metrics,
|
||||
{
|
||||
...metricsByLabel.reduce(
|
||||
(metricSum, metric) => ({
|
||||
interval: metric.interval,
|
||||
availableTime: metricSum.availableTime + metric.availableTime,
|
||||
tourAmount: metricSum.tourAmount + metric.tourAmount,
|
||||
breakEven: metricSum.breakEven + metric.breakEven,
|
||||
revenue: metricSum.revenue + metric.revenue,
|
||||
averageRevenue: metric.averageRevenue, // (metricSum.averageRevenue * index + metric.averageRevenue) / (index +1),
|
||||
personnelCost: metricSum.personnelCost + metric.personnelCost,
|
||||
consumptionCost: metricSum.consumptionCost + metric.consumptionCost,
|
||||
forecast: metricSum.forecast + metric.forecast,
|
||||
forecastRevenue: metricSum.forecastRevenue + metric.forecastRevenue,
|
||||
categoryAmounts: metric.categoryAmounts,
|
||||
}),
|
||||
{
|
||||
interval: {
|
||||
start: new Date(),
|
||||
end: new Date(),
|
||||
},
|
||||
availableTime: 0,
|
||||
tourAmount: 0,
|
||||
breakEven: 0,
|
||||
revenue: 0,
|
||||
averageRevenue: 0,
|
||||
personnelCost: 0,
|
||||
consumptionCost: 0,
|
||||
forecast: 0,
|
||||
forecastRevenue: 0,
|
||||
categoryAmounts: [],
|
||||
} as TimePerTourMetric
|
||||
),
|
||||
averageRevenue:
|
||||
metricsByLabel.reduce(
|
||||
(sum, { averageRevenue }) => (sum += averageRevenue),
|
||||
0
|
||||
) /
|
||||
metricsByLabel.filter(({ averageRevenue }) => averageRevenue > 0)
|
||||
.length || 0,
|
||||
categoryAmounts: Object.entries(
|
||||
groupBy(
|
||||
metricsByLabel.flatMap(({ categoryAmounts }) => categoryAmounts),
|
||||
({ category }) => category
|
||||
)
|
||||
).reduce(
|
||||
(categoryAmounts, [category, amounts]) => [
|
||||
...categoryAmounts,
|
||||
{
|
||||
category,
|
||||
amount: amounts.reduce((sum, { amount }) => (sum += amount), 0),
|
||||
},
|
||||
],
|
||||
[] as TimePerTourMetricCategoryAmount[]
|
||||
),
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { eachHourOfInterval } from 'date-fns';
|
||||
import { TimePerTourMetric } from '../../../core/data-access/graphql/generated/generated';
|
||||
|
||||
export function summarizeMetrics(
|
||||
metrics: TimePerTourMetric[]
|
||||
): TimePerTourMetric {
|
||||
const metricInterval = {
|
||||
start: new Date(metrics[0].interval.start),
|
||||
end: new Date(metrics[0].interval.end),
|
||||
};
|
||||
return {
|
||||
...metrics.reduce(
|
||||
(sum, metric) => ({
|
||||
...sum,
|
||||
interval: metricInterval,
|
||||
availableTime: sum.availableTime + metric.availableTime,
|
||||
tourAmount: sum.tourAmount + metric.tourAmount,
|
||||
breakEven: sum.breakEven + metric.breakEven,
|
||||
revenue: sum.revenue + metric.revenue,
|
||||
personnelCost: sum.personnelCost + metric.personnelCost,
|
||||
consumptionCost: sum.consumptionCost + metric.consumptionCost,
|
||||
forecast:
|
||||
eachHourOfInterval(metricInterval).length <= 1
|
||||
? metric.forecast
|
||||
: Math.round(sum.forecast) + Math.round(metric.forecast),
|
||||
forecastRevenue:
|
||||
eachHourOfInterval(metricInterval).length <= 1
|
||||
? metric.forecastRevenue
|
||||
: sum.forecastRevenue + metric.forecastRevenue,
|
||||
}),
|
||||
{
|
||||
interval: { start: new Date(), end: new Date() },
|
||||
availableTime: 0,
|
||||
tourAmount: 0,
|
||||
breakEven: 0,
|
||||
revenue: 0,
|
||||
averageRevenue: 0,
|
||||
personnelCost: 0,
|
||||
consumptionCost: 0,
|
||||
forecast: 0,
|
||||
forecastRevenue: 0,
|
||||
categoryAmounts: [],
|
||||
}
|
||||
),
|
||||
averageRevenue:
|
||||
metrics.reduce((sum, { averageRevenue }) => (sum += averageRevenue), 0) /
|
||||
metrics.filter(({ averageRevenue }) => averageRevenue > 0).length || 0,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user