init dashboard

This commit is contained in:
Marcel Arndt
2025-04-10 17:31:52 +02:00
parent 98aeb82ff6
commit 52c1a14609
178 changed files with 12306 additions and 401 deletions
@@ -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 {}
@@ -0,0 +1,7 @@
<div
*ngIf="echartsOptions$ | async as echartsOptions"
dksEcharts
[defaultHeight]="300"
[options]="echartsOptions"
[extentions]="echartsExtensions"
></div>
@@ -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();
});
});
@@ -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,
},
],
};
}
}
@@ -0,0 +1,3 @@
:host {
@apply flex justify-center items-center;
}
@@ -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>
@@ -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();
});
});
@@ -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))
);
}
@@ -0,0 +1,7 @@
<div
*ngIf="echartsOptions$ | async as echartsOptions"
dksEcharts
[defaultHeight]="300"
[options]="echartsOptions"
[extentions]="echartsExtensions"
></div>
@@ -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();
});
});
@@ -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[])
: []),
],
};
}
}
@@ -0,0 +1,3 @@
:host {
@apply flex justify-center items-center;
}
@@ -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>
@@ -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();
});
});
@@ -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))
);
}
@@ -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>
@@ -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();
});
});
@@ -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
);
}
}
}
@@ -0,0 +1,7 @@
<div
*ngIf="echartsOptions$ | async as echartsOptions"
dksEcharts
[defaultHeight]="300"
[options]="echartsOptions"
[extentions]="echartsExtensions"
></div>
@@ -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();
});
});
@@ -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[])
// : []),
],
};
}
}
@@ -0,0 +1,3 @@
:host {
@apply flex justify-center items-center;
}
@@ -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>
@@ -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();
});
});
@@ -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))
);
}
@@ -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>
@@ -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();
});
});
@@ -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;
})
);
}
}
@@ -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>
@@ -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();
});
});
@@ -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) };
}
}
@@ -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);
}
@@ -0,0 +1,3 @@
.full-width-table {
width: 100%;
}
@@ -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>
@@ -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();
});
});
@@ -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;
}
}
}
@@ -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>
@@ -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();
});
});
@@ -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,
};
}