diff --git a/src/app/app.component.html b/src/app/app.component.html index 7a6451c..c7f8a18 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -1,11 +1,3 @@
- - +
\ No newline at end of file diff --git a/src/app/app.module.ts b/src/app/app.module.ts index e706820..6085d13 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -4,7 +4,7 @@ import { MaterialModule } from '../material.module'; import { AppComponent } from './app.component'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { UsageComponent } from './components/usage/usage.component'; +import { BottomSheetOverviewExampleSheetComponent, UsageComponent } from './components/usage/usage.component'; import { FileUploadComponent } from './components/usage/file-upload/file-upload.component'; import { ChartPieUserComponent } from './components/usage/actions/charts/chart-pie-user/chart-pie-user.component'; import { TableWorkflowUsageComponent } from './components/usage/actions/table-workflow-usage/table-workflow-usage.component'; @@ -28,6 +28,7 @@ import { TableCodespacesUsageComponent } from './components/usage/codespaces/tab @NgModule({ declarations: [ AppComponent, UsageComponent, + BottomSheetOverviewExampleSheetComponent, ChartPieUserComponent, ChartLineUsageDailyComponent, ChartBarTopTimeComponent, diff --git a/src/app/components/usage/actions/actions.component.html b/src/app/components/usage/actions/actions.component.html index e5373ac..7c33ecd 100644 --- a/src/app/components/usage/actions/actions.component.html +++ b/src/app/components/usage/actions/actions.component.html @@ -1,4 +1,4 @@ - +
diff --git a/src/app/components/usage/actions/actions.component.ts b/src/app/components/usage/actions/actions.component.ts index 1ae1579..24fb8d6 100644 --- a/src/app/components/usage/actions/actions.component.ts +++ b/src/app/components/usage/actions/actions.component.ts @@ -1,5 +1,5 @@ import { Component, Input, OnInit } from '@angular/core'; -import { CustomUsageReportLine, UsageReportService } from 'src/app/usage-report.service'; +import { AggregationType, CustomUsageReportLine, UsageReportService } from 'src/app/usage-report.service'; @Component({ selector: 'app-actions', @@ -9,7 +9,9 @@ import { CustomUsageReportLine, UsageReportService } from 'src/app/usage-report. }) export class ActionsComponent implements OnInit { @Input() data!: CustomUsageReportLine[]; - @Input() currency!: string; + @Input() currency!: 'cost' | 'minutes'; + @Input() grouping!: AggregationType; + @Input() filter: string = ''; totalMinutes: number = 0; totalCost: number = 0; diff --git a/src/app/components/usage/actions/charts/chart-line-usage-daily/chart-line-usage-daily.component.ts b/src/app/components/usage/actions/charts/chart-line-usage-daily/chart-line-usage-daily.component.ts index cfe53e0..2a6de46 100644 --- a/src/app/components/usage/actions/charts/chart-line-usage-daily/chart-line-usage-daily.component.ts +++ b/src/app/components/usage/actions/charts/chart-line-usage-daily/chart-line-usage-daily.component.ts @@ -1,7 +1,7 @@ import { Component, Input, OnChanges, ViewChild } from '@angular/core'; import * as Highcharts from 'highcharts'; import { ThemingService } from 'src/app/theme.service'; -import { CustomUsageReportLine, UsageReportService } from 'src/app/usage-report.service'; +import { AggregationType, CustomUsageReportLine, UsageReportService } from 'src/app/usage-report.service'; @Component({ selector: 'app-chart-line-usage-daily', @@ -51,7 +51,7 @@ export class ChartLineUsageDailyComponent implements OnChanges { }, }; updateFromInput: boolean = false; - chartType: 'repo' | 'total' | 'sku' | 'user' | 'workflow' = 'sku'; + chartType: AggregationType = 'sku'; timeType: 'total' | 'run' | 'daily' | 'weekly' | 'monthly' | 'rolling30' | 'rolling7' = 'rolling30'; rollingDays = 30; @@ -71,6 +71,7 @@ export class ChartLineUsageDailyComponent implements OnChanges { (acc, line, index) => { let name = 'Total'; let timeKey = 'total'; + if (this.timeType === 'run') { timeKey = `${line.workflowName}${line.date}${index}`; } else if (this.timeType === 'daily') { @@ -78,25 +79,23 @@ export class ChartLineUsageDailyComponent implements OnChanges { } else if (this.timeType === 'weekly') { timeKey = this.getWeekOfYear(line.date).toString(); } else if (this.timeType === 'monthly') { - // get key in format YYYY-MM timeKey = line.date.toISOString().split('T')[0].slice(0, 7); } else if (this.timeType.startsWith('rolling')) { - // get key in format YYYY-MM timeKey = line.date.toISOString().split('T')[0]; } else if (this.timeType === 'total') { timeKey = 'total' } + if (this.chartType === 'sku') { name = this.usageReportService.formatSku(line.sku); - } else if (this.chartType === 'user') { + } else if (this.chartType === 'username') { name = line.username; - } else if (this.chartType === 'repo') { + } else if (this.chartType === 'repositoryName') { name = line.repositoryName; } else if (this.chartType === 'workflow') { name = line.workflowName; - } else if (this.chartType === 'total') { - name = 'total'; } + const series = acc.find((s) => s.name === name); if (series) { if (!series.data[timeKey]) series.data[timeKey] = []; @@ -124,6 +123,7 @@ export class ChartLineUsageDailyComponent implements OnChanges { ).sort((a: any, b: any) => { return b.total - a.total; }).slice(0, 50); + (this.options.series as { name: string; data: [number, number][] }[]) = seriesDays.map((series) => { let data: [number, number][] = []; if (this.timeType === 'total') { @@ -161,7 +161,7 @@ export class ChartLineUsageDailyComponent implements OnChanges { data } }); - if (this.options.legend) this.options.legend.enabled = this.chartType === 'total' ? false : true; + // if (this.options.legend) this.options.legend.enabled = this.chartType === 'total' ? false : true; this.options.yAxis = { ...this.options.yAxis, title: { diff --git a/src/app/components/usage/actions/table-workflow-usage/table-workflow-usage.component.html b/src/app/components/usage/actions/table-workflow-usage/table-workflow-usage.component.html index 7f7130d..2edb329 100644 --- a/src/app/components/usage/actions/table-workflow-usage/table-workflow-usage.component.html +++ b/src/app/components/usage/actions/table-workflow-usage/table-workflow-usage.component.html @@ -1,30 +1,4 @@
-
- - Grouping - - Runner - Repo - Workflow - User - - - - - Filter - - - -
-
@@ -48,7 +22,7 @@ - +
No data matching the filter "{{input.value}}"No data matching the filter "{{filter}}"
diff --git a/src/app/components/usage/actions/table-workflow-usage/table-workflow-usage.component.ts b/src/app/components/usage/actions/table-workflow-usage/table-workflow-usage.component.ts index 62ad84c..046d59f 100644 --- a/src/app/components/usage/actions/table-workflow-usage/table-workflow-usage.component.ts +++ b/src/app/components/usage/actions/table-workflow-usage/table-workflow-usage.component.ts @@ -1,51 +1,9 @@ -import { AfterViewInit, Component, Input, OnChanges, ViewChild } from '@angular/core'; +import { AfterViewInit, Component, Input, OnChanges, ViewChild, Pipe, PipeTransform, SimpleChanges } from '@angular/core'; import { MatPaginator } from '@angular/material/paginator'; import { MatTableDataSource } from '@angular/material/table'; import { MatSort } from '@angular/material/sort'; -import { CustomUsageReportLine, UsageReportService } from 'src/app/usage-report.service'; - -interface WorkflowUsageItem { - workflow: string; - avgTime: number; - avgCost: number; - runs: number; - repo: string; - total: number; - cost: number; - pricePerUnit: number; - sku: string; - username: string; -} - -interface RepoUsageItem { - avgTime: number; - avgCost: number; - repo: string; - runs: number; - total: number; - cost: number; - sku: string; -} - -interface SkuUsageItem { - avgTime: number; - avgCost: number; - sku: string; - runs: number; - total: number; - cost: number; -} - -interface UsageColumn { - sticky?: boolean; - columnDef: string; - header: string; - cell: (element: any) => any; - footer?: () => any; - tooltip?: (element: any) => any; - icon?: (element: any) => string; - date?: Date; -} +import { UsageReportService, WorkflowUsageItem, RepoUsageItem, SkuUsageItem, UserUsageItem, UsageColumn, AggregatedUsageData, AggregationType, Product } from 'src/app/usage-report.service'; +import { CurrencyPipe, DecimalPipe } from '@angular/common'; @Component({ selector: 'app-table-workflow-usage', @@ -54,13 +12,15 @@ interface UsageColumn { standalone: false }) export class TableWorkflowUsageComponent implements OnChanges, AfterViewInit { - columns = [] as UsageColumn[]; + baseColumns = [] as UsageColumn[]; monthColumns = [] as UsageColumn[]; - displayedColumns = this.columns.map(c => c.columnDef); - @Input() data!: CustomUsageReportLine[]; - @Input() currency!: string; - dataSource: MatTableDataSource = new MatTableDataSource(); // Initialize the dataSource property - tableType: 'workflow' | 'repo' | 'sku' | 'user' = 'sku'; + columns = [] as UsageColumn[]; + displayedColumns: string[] = []; + @Input() currency!: 'minutes' | 'cost'; + @Input() tableType!: AggregationType; + @Input() product: Product | Product[] = 'actions'; + @Input() filter: string = ''; + dataSource: MatTableDataSource = new MatTableDataSource(); @ViewChild(MatPaginator) paginator!: MatPaginator; @ViewChild(MatSort) sort!: MatSort; @@ -69,113 +29,87 @@ export class TableWorkflowUsageComponent implements OnChanges, AfterViewInit { private usageReportService: UsageReportService, ) { } - ngOnChanges() { - this.initializeColumns(); - let usage: WorkflowUsageItem[] | RepoUsageItem[] | SkuUsageItem[] = []; - let usageItems: WorkflowUsageItem[] = (usage as WorkflowUsageItem[]); - usageItems = this.data.reduce((acc, line) => { - const item = acc.find(a => { - if (this.tableType === 'workflow') { - return a.workflow === line.workflowName - } else if (this.tableType === 'repo') { - return a.repo === line.repositoryName; - } else if (this.tableType === 'sku') { - return a.sku === this.usageReportService.formatSku(line.sku); - } else if (this.tableType === 'user') { - return a.username === line.username; - } - return false - }); - const month: string = line.date.toLocaleString('default', { month: 'short', year: '2-digit'}); - if (item) { - if ((item as any)[month]) { - (item as any)[month] += line.value; - } else { - (item as any)[month] = line.value || 0; - } - if (!this.columns.find(c => c.columnDef === month)) { - const column: UsageColumn = { - columnDef: month, - header: month, - cell: (workflowItem: any) => this.currency === 'cost' ? currencyPipe.transform(workflowItem[month]) : decimalPipe.transform(workflowItem[month]), - footer: () => { - const total = this.dataSource.data.reduce((acc, item) => acc + (item as any)[month], 0); - return this.currency === 'cost' ? currencyPipe.transform(total) : decimalPipe.transform(total); - }, - date: new Date(line.date), - }; - const lastMonth: string = new Date(line.date.getFullYear(), line.date.getMonth() - 1).toLocaleString('default', { month: 'short' }); - const lastMonthValue = (item as any)[lastMonth]; - if (lastMonthValue) { - column.tooltip = (workflowItem: WorkflowUsageItem) => { - return (workflowItem as any)[month + 'PercentChange']?.toFixed(2) + '%'; - }; - column.icon = (workflowItem: WorkflowUsageItem) => { - const percentageChanged = (workflowItem as any)[month + 'PercentChange']; - if (percentageChanged > 0) { - return 'trending_up'; - } else if (percentageChanged < 0) { - return 'trending_down'; - } else { - return 'trending_flat'; - } - }; - } - this.columns.push(column); - this.monthColumns.push(column); - } - item.cost += line.quantity * line.pricePerUnit; - item.total += line.quantity; - item.runs++; - } else { - acc.push({ - workflow: line.workflowName, - repo: line.repositoryName, - total: line.quantity, - cost: line.quantity * line.pricePerUnit, - runs: 1, - pricePerUnit: line.pricePerUnit || 0, - avgCost: line.quantity * line.pricePerUnit, - avgTime: line.value, - [month]: line.value, - sku: this.usageReportService.formatSku(line.sku), - username: line.username, - }); + ngOnChanges(changes: SimpleChanges) { + if (changes['filter'] && !changes['filter'].isFirstChange()) { + this.dataSource.filter = this.filter.trim().toLowerCase(); + + if (this.dataSource.paginator) { + this.dataSource.paginator.firstPage(); } - return acc; - }, [] as WorkflowUsageItem[]); + return; + } + this.initializeColumns(); - usageItems.forEach((item) => { - this.monthColumns.forEach((column: UsageColumn) => { - const month = column.columnDef; - if (!(item as any)[month]) { - (item as any)[month] = 0; - } - const lastMonth: string = new Date(new Date().getFullYear(), this.usageReportService.monthsOrder.indexOf(month) - 1).toLocaleString('default', { month: 'short' }); - const lastMonthValue = (item as any)[lastMonth]; - const percentageChanged = this.calculatePercentageChange(lastMonthValue, (item as any)[month]); - (item as any)[month + 'PercentChange'] = percentageChanged; + // Use the service to get aggregated data + this.usageReportService.getAggregatedUsageData(this.tableType, this.product).subscribe((aggregatedData: AggregatedUsageData) => { + // Create month columns using the service helper method + this.monthColumns = this.usageReportService.createMonthColumns(aggregatedData.availableMonths, this.currency, this.dataSource); + this.updateMonthColumnFormatting(); + + // Combine base columns with month columns and sort them properly + const allColumns = [...this.baseColumns, ...this.monthColumns]; + this.columns = allColumns.sort((a, b) => { + // Keep non-date columns first, then sort date columns chronologically + if (!a.date && !b.date) return 0; + if (!a.date) return -1; + if (!b.date) return 1; + return a.date.getTime() - b.date.getTime(); }); + + this.displayedColumns = this.columns.map(c => c.columnDef); + this.dataSource.data = aggregatedData.items; + }); + } - item.avgTime = item.total / item.runs; - item.avgCost = item.cost / item.runs; + private updateMonthColumnFormatting() { + this.monthColumns.forEach(column => { + // Update cell formatting based on currency + column.cell = (workflowItem: any) => + this.currency === 'cost' + ? currencyPipe.transform(workflowItem[column.columnDef]) + : decimalPipe.transform(workflowItem[column.columnDef]); + + // Update footer formatting + const originalFooter = column.footer; + column.footer = () => { + const total = originalFooter ? originalFooter() : 0; + return this.currency === 'cost' + ? currencyPipe.transform(total) + : decimalPipe.transform(total); + }; + + // Add tooltip and icon functionality for percentage changes + const month = column.columnDef; + column.tooltip = (workflowItem: any) => { + const percentChange = workflowItem[month + 'PercentChange']; + return percentChange !== undefined ? percentChange.toFixed(2) + '%' : ''; + }; + + column.icon = (workflowItem: any) => { + const percentageChanged = workflowItem[month + 'PercentChange']; + if (percentageChanged > 0) { + return 'trending_up'; + } else if (percentageChanged < 0) { + return 'trending_down'; + } else { + return 'trending_flat'; + } + }; }); - usage = usageItems; - this.columns = this.columns.sort((a, b) => (!a.date || !b.date) ? 0 : a.date.getTime() - b.date.getTime()); - this.displayedColumns = this.columns.map(c => c.columnDef); - this.dataSource.data = usage; } ngAfterViewInit() { this.dataSource.paginator = this.paginator; this.dataSource.sort = this.sort; const initial = this.dataSource.sortData; - this.dataSource.sortData = (data: (WorkflowUsageItem | RepoUsageItem | SkuUsageItem)[], sort: MatSort) => { + this.dataSource.sortData = (data: (WorkflowUsageItem | RepoUsageItem | SkuUsageItem | UserUsageItem)[], sort: MatSort) => { switch (sort.active) { case 'sku': - return data.sort((a, b) => { - const orderA = this.usageReportService.skuOrder.indexOf(a.sku); - const orderB = this.usageReportService.skuOrder.indexOf(b.sku); + return data.sort((a, b) => { + const skuA = 'sku' in a ? a.sku : ''; + const skuB = 'sku' in b ? b.sku : ''; + const orderA = this.usageReportService.skuOrder.indexOf(skuA); + const orderB = this.usageReportService.skuOrder.indexOf(skuB); return sort.direction === 'asc' ? orderA - orderB : orderB - orderA; }); default: @@ -184,15 +118,6 @@ export class TableWorkflowUsageComponent implements OnChanges, AfterViewInit { }; } - applyFilter(event: Event) { - const filterValue = (event.target as HTMLInputElement).value; - this.dataSource.filter = filterValue.trim().toLowerCase(); - - if (this.dataSource.paginator) { - this.dataSource.paginator.firstPage(); - } - } - initializeColumns() { let columns: UsageColumn[] = []; if (this.tableType === 'workflow') { @@ -200,13 +125,13 @@ export class TableWorkflowUsageComponent implements OnChanges, AfterViewInit { { columnDef: 'workflow', header: 'Workflow', - cell: (workflowItem: WorkflowUsageItem) => `${workflowItem.workflow}`, + cell: (workflowItem: WorkflowUsageItem) => `${workflowItem.workflowName}`, sticky: true, }, { - columnDef: 'repo', + columnDef: 'repositoryName', header: 'Repository', - cell: (workflowItem: WorkflowUsageItem) => `${workflowItem.repo}`, + cell: (workflowItem: WorkflowUsageItem) => `${workflowItem.repositoryName}`, }, { columnDef: 'runner', @@ -214,12 +139,12 @@ export class TableWorkflowUsageComponent implements OnChanges, AfterViewInit { cell: (workflowItem: WorkflowUsageItem) => `${workflowItem.sku}`, }, ]; - } else if (this.tableType === 'repo') { + } else if (this.tableType === 'repositoryName') { columns = [ { - columnDef: 'repo', - header: 'Source repository', - cell: (workflowItem: WorkflowUsageItem) => `${workflowItem.repo}`, + columnDef: 'repositoryName', + header: 'Repository', + cell: (workflowItem: WorkflowUsageItem) => `${workflowItem.repositoryName}`, sticky: true, }, ]; @@ -232,7 +157,7 @@ export class TableWorkflowUsageComponent implements OnChanges, AfterViewInit { sticky: true, }, ]; - } else if (this.tableType === 'user') { + } else if (this.tableType === 'username') { columns = [ { columnDef: 'username', @@ -241,7 +166,35 @@ export class TableWorkflowUsageComponent implements OnChanges, AfterViewInit { sticky: true, }, ]; + } else if (this.tableType === 'organization') { + columns = [ + { + columnDef: 'organization', + header: 'Organization', + cell: (workflowItem: WorkflowUsageItem) => workflowItem.organization, + sticky: true, + }, + ]; + } else if (this.tableType === 'costCenterName') { + columns = [ + { + columnDef: 'costCenterName', + header: 'Cost Center', + cell: (workflowItem: WorkflowUsageItem) => workflowItem.costCenterName, + sticky: true, + }, + ]; + } else if (this.tableType === 'workflowPath') { + columns = [ + { + columnDef: 'workflowPath', + header: 'Workflow Path', + cell: (workflowItem: WorkflowUsageItem) => workflowItem.workflowPath, + sticky: true, + }, + ]; } + columns.push({ columnDef: 'runs', header: 'Runs', @@ -256,40 +209,45 @@ export class TableWorkflowUsageComponent implements OnChanges, AfterViewInit { columnDef: 'avgTime', header: 'Avg time', cell: (workflowItem: WorkflowUsageItem) => `${durationPipe.transform(workflowItem.avgTime)}`, - footer: () => durationPipe.transform(this.dataSource.data.reduce((acc, line) => acc += line.avgTime, 0) / this.dataSource.data.length) + footer: () => { + const avgTime = this.dataSource.data.reduce((acc: number, line: any) => acc + line.avgTime, 0) / this.dataSource.data.length; + return durationPipe.transform(avgTime); + } }, { columnDef: 'total', header: 'Total', cell: (workflowItem: WorkflowUsageItem) => decimalPipe.transform(Math.floor(workflowItem.total)), - footer: () => decimalPipe.transform(this.data.reduce((acc, line) => acc += line.value, 0)) + footer: () => { + const total = this.dataSource.data.reduce((acc: number, line: any) => acc + line.total, 0); + return decimalPipe.transform(total); + } }); } else if (this.currency === 'cost') { columns.push({ columnDef: 'avgCost', header: 'Avg run', cell: (workflowItem: WorkflowUsageItem) => currencyPipe.transform(workflowItem.avgCost), - footer: () => currencyPipe.transform(this.dataSource.data.reduce((acc, line) => acc += line.cost, 0) / this.dataSource.data.length) + footer: () => { + const avgCost = this.dataSource.data.reduce((acc: number, line: any) => acc + line.cost, 0) / this.dataSource.data.length; + return currencyPipe.transform(avgCost); + } }, { columnDef: 'cost', header: 'Total', cell: (workflowItem: WorkflowUsageItem) => currencyPipe.transform(workflowItem.cost), - footer: () => currencyPipe.transform(this.data.reduce((acc, line) => acc += line.value, 0)) + footer: () => { + const total = this.dataSource.data.reduce((acc: number, line: any) => acc + line.cost, 0); + return currencyPipe.transform(total); + } }); } columns[0].footer = () => 'Total'; - this.columns = columns; - this.monthColumns = []; - this.displayedColumns = this.columns.map(c => c.columnDef); - } - - calculatePercentageChange(oldValue: number, newValue: number) { - return (oldValue === 0) ? 0 : ((newValue - oldValue) / oldValue) * 100; + this.baseColumns = columns; + this.monthColumns = []; // Reset month columns + this.displayedColumns = this.baseColumns.map(c => c.columnDef); } } -import { Pipe, PipeTransform } from '@angular/core'; -import { CurrencyPipe, DecimalPipe } from '@angular/common'; - @Pipe({ name: 'duration', standalone: false diff --git a/src/app/components/usage/codespaces/codespaces.component.html b/src/app/components/usage/codespaces/codespaces.component.html index 500fc1b..96ebaea 100644 --- a/src/app/components/usage/codespaces/codespaces.component.html +++ b/src/app/components/usage/codespaces/codespaces.component.html @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/app/components/usage/codespaces/codespaces.component.ts b/src/app/components/usage/codespaces/codespaces.component.ts index 583b24e..e941435 100644 --- a/src/app/components/usage/codespaces/codespaces.component.ts +++ b/src/app/components/usage/codespaces/codespaces.component.ts @@ -9,5 +9,5 @@ import { CustomUsageReportLine } from 'src/app/usage-report.service'; }) export class CodespacesComponent { @Input() data!: CustomUsageReportLine[]; - @Input() currency!: string; + @Input() currency!: 'minutes' | 'cost'; } diff --git a/src/app/components/usage/codespaces/table-codespaces-usage/table-codespaces-usage-clean.component.ts b/src/app/components/usage/codespaces/table-codespaces-usage/table-codespaces-usage-clean.component.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/app/components/usage/codespaces/table-codespaces-usage/table-codespaces-usage.component.html b/src/app/components/usage/codespaces/table-codespaces-usage/table-codespaces-usage.component.html index ea41fad..535df22 100644 --- a/src/app/components/usage/codespaces/table-codespaces-usage/table-codespaces-usage.component.html +++ b/src/app/components/usage/codespaces/table-codespaces-usage/table-codespaces-usage.component.html @@ -1,13 +1,5 @@
- - Grouping - - Product - Repository - User - - Filter diff --git a/src/app/components/usage/codespaces/table-codespaces-usage/table-codespaces-usage.component.ts b/src/app/components/usage/codespaces/table-codespaces-usage/table-codespaces-usage.component.ts index df43cf1..10d178f 100644 --- a/src/app/components/usage/codespaces/table-codespaces-usage/table-codespaces-usage.component.ts +++ b/src/app/components/usage/codespaces/table-codespaces-usage/table-codespaces-usage.component.ts @@ -2,28 +2,10 @@ import { AfterViewInit, Component, Input, OnChanges, ViewChild } from '@angular/ import { MatPaginator } from '@angular/material/paginator'; import { MatTableDataSource } from '@angular/material/table'; import { MatSort } from '@angular/material/sort'; -import { CustomUsageReportLine, UsageReportService } from 'src/app/usage-report.service'; +import { UsageReportService, WorkflowUsageItem, RepoUsageItem, SkuUsageItem, UserUsageItem, UsageColumn, AggregatedUsageData, AggregationType } from 'src/app/usage-report.service'; +import { CurrencyPipe, DecimalPipe } from '@angular/common'; -interface UsageColumn { - columnDef: string; - header: string; - cell: (element: any) => any; - footer?: () => any; - sticky?: boolean; -} - -interface CodespacesUsageItem { - runs: number; - total: number; - cost: number; - pricePerUnit: number; - owner: string; - username: string; - sku: string; - unitType: string; - repositorySlug: string; - sticky?: boolean; -} +type Product = 'git_lfs' | 'packages' | 'copilot' | 'actions' | 'codespaces'; @Component({ selector: 'app-table-codespaces-usage', @@ -33,11 +15,12 @@ interface CodespacesUsageItem { }) export class TableCodespacesUsageComponent implements OnChanges, AfterViewInit { columns = [] as UsageColumn[]; - displayedColumns = this.columns.map(c => c.columnDef); - @Input() data!: CustomUsageReportLine[]; - @Input() currency!: string; - dataSource: MatTableDataSource = new MatTableDataSource(); // Initialize the dataSource property - tableType: 'sku' | 'repo' | 'user' = 'sku'; + monthColumns = [] as UsageColumn[]; + displayedColumns: string[] = []; + @Input() currency!: 'minutes' | 'cost'; + @Input() tableType!: AggregationType; + @Input() product: Product | Product[] = 'codespaces'; + dataSource: MatTableDataSource = new MatTableDataSource(); @ViewChild(MatPaginator) paginator!: MatPaginator; @ViewChild(MatSort) sort!: MatSort; @@ -48,68 +31,54 @@ export class TableCodespacesUsageComponent implements OnChanges, AfterViewInit { ngOnChanges() { this.initializeColumns(); - let usage: CodespacesUsageItem[] = []; - let usageItems: CodespacesUsageItem[] = (usage as CodespacesUsageItem[]); - usageItems = this.data.reduce((acc, line) => { - const item = acc.find(a => { - if (this.tableType === 'sku') { - return a.sku === line.sku; - } else if (this.tableType === 'repo') { - return a.repositorySlug === line.repositoryName; - } else if (this.tableType === 'user') { - return a.username === line.username; - } - return false; - }); - const month: string = line.date.toLocaleString('default', { month: 'short' }); - if (item) { - if ((item as any)[month]) { - (item as any)[month] += line.value; - } else { - (item as any)[month] = line.value || 0; - } - item.total += line.value; - if (!this.columns.find(c => c.columnDef === month)) { - this.columns.push({ - columnDef: month, - header: month, - cell: (workflowItem: any) => this.currency === 'cost' ? currencyPipe.transform(workflowItem[month]) : decimalPipe.transform(workflowItem[month]), - footer: () => { - const total = this.dataSource.data.reduce((acc, item) => acc + (item as any)[month], 0); - return this.currency === 'cost' ? currencyPipe.transform(total) : decimalPipe.transform(total); - } - }); - } - item.cost += line.quantity * line.pricePerUnit; - item.total += line.quantity; - item.runs++; - } else { - acc.push({ - owner: line.organization, - total: line.quantity, - cost: line.quantity * line.pricePerUnit, - runs: 1, - pricePerUnit: line.pricePerUnit || 0, - [month]: line.value, - sku: line.sku, - unitType: line.unitType, - repositorySlug: line.repositoryName, - username: line.username - }); - } - return acc; - }, [] as CodespacesUsageItem[]); + + // Use the service to get aggregated data + this.usageReportService.getAggregatedUsageData(this.tableType, this.product).subscribe((aggregatedData: AggregatedUsageData) => { + // Create month columns using the service helper method + this.monthColumns = this.usageReportService.createMonthColumns(aggregatedData.availableMonths, this.currency, this.dataSource); + this.updateMonthColumnFormatting(); + this.columns = [...this.columns, ...this.monthColumns]; + this.columns = this.columns.sort((a, b) => (!a.date || !b.date) ? 0 : a.date.getTime() - b.date.getTime()); + this.displayedColumns = this.columns.map(c => c.columnDef); + this.dataSource.data = aggregatedData.items; + }); + } + + private updateMonthColumnFormatting() { + this.monthColumns.forEach(column => { + // Update cell formatting based on currency + column.cell = (item: any) => + this.currency === 'cost' + ? currencyPipe.transform(item[column.columnDef]) + : decimalPipe.transform(item[column.columnDef]); + + // Update footer formatting + const originalFooter = column.footer; + column.footer = () => { + const total = originalFooter ? originalFooter() : 0; + return this.currency === 'cost' + ? currencyPipe.transform(total) + : decimalPipe.transform(total); + }; - usageItems.forEach((item) => { - this.columns.forEach((column: any) => { - if (!(item as any)[column.columnDef]) { - (item as any)[column.columnDef] = 0; + // Add tooltip and icon functionality for percentage changes + const month = column.columnDef; + column.tooltip = (item: any) => { + const percentChange = item[month + 'PercentChange']; + return percentChange !== undefined ? percentChange.toFixed(2) + '%' : ''; + }; + + column.icon = (item: any) => { + const percentageChanged = item[month + 'PercentChange']; + if (percentageChanged > 0) { + return 'trending_up'; + } else if (percentageChanged < 0) { + return 'trending_down'; + } else { + return 'trending_flat'; } - }); + }; }); - usage = usageItems - this.displayedColumns = this.columns.map(c => c.columnDef); - this.dataSource.data = usage; } ngAfterViewInit() { @@ -133,51 +102,60 @@ export class TableCodespacesUsageComponent implements OnChanges, AfterViewInit { { columnDef: 'sku', header: 'Product', - cell: (workflowItem: CodespacesUsageItem) => `${workflowItem.sku}`, + cell: (item: SkuUsageItem) => `${item.sku}`, sticky: true } ]; - } else if (this.tableType === 'repo') { + } else if (this.tableType === 'repositoryName') { columns = [ { columnDef: 'repo', header: 'Repository', - cell: (workflowItem: CodespacesUsageItem) => `${workflowItem.repositorySlug}`, + cell: (item: RepoUsageItem) => `${item.repositoryName}`, sticky: true } ]; - } else if (this.tableType === 'user') { + } else if (this.tableType === 'username') { columns = [ { columnDef: 'username', header: 'User', - cell: (workflowItem: CodespacesUsageItem) => `${workflowItem.username}`, + cell: (item: UserUsageItem) => `${item.username}`, sticky: true } ]; } + if (this.currency === 'minutes') { columns.push({ columnDef: 'total', - header: 'Total seats', - cell: (workflowItem: CodespacesUsageItem) => decimalPipe.transform(Math.floor(workflowItem.total)), - footer: () => decimalPipe.transform(this.data.reduce((acc, line) => acc += line.value, 0)) + header: 'Total hours', + cell: (item: any) => decimalPipe.transform(Math.floor(item.total)), + footer: () => { + if (!this.dataSource?.data) return ''; + const total = this.dataSource.data.reduce((acc: number, item: any) => acc + (item.total || 0), 0); + return decimalPipe.transform(total); + } }); } else if (this.currency === 'cost') { columns.push({ columnDef: 'cost', header: 'Total cost', - cell: (workflowItem: CodespacesUsageItem) => currencyPipe.transform(workflowItem.cost), - footer: () => currencyPipe.transform(this.data.reduce((acc, line) => acc += line.value, 0)) + cell: (item: any) => currencyPipe.transform(item.cost), + footer: () => { + if (!this.dataSource?.data) return ''; + const total = this.dataSource.data.reduce((acc: number, item: any) => acc + (item.cost || 0), 0); + return currencyPipe.transform(total); + } }); } + this.columns = columns; this.displayedColumns = this.columns.map(c => c.columnDef); } } import { Pipe, PipeTransform } from '@angular/core'; -import { CurrencyPipe, DecimalPipe } from '@angular/common'; @Pipe({ name: 'duration', @@ -194,7 +172,6 @@ export class DurationPipe implements PipeTransform { return `${Math.round(seconds / 3600)} hr`; } } - } const decimalPipe = new DecimalPipe('en-US'); diff --git a/src/app/components/usage/copilot/copilot.component.html b/src/app/components/usage/copilot/copilot.component.html index fe11cc1..e3a3745 100644 --- a/src/app/components/usage/copilot/copilot.component.html +++ b/src/app/components/usage/copilot/copilot.component.html @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/app/components/usage/copilot/copilot.component.ts b/src/app/components/usage/copilot/copilot.component.ts index bec29b5..501bb3e 100644 --- a/src/app/components/usage/copilot/copilot.component.ts +++ b/src/app/components/usage/copilot/copilot.component.ts @@ -1,5 +1,5 @@ import { Component, Input } from '@angular/core'; -import { CustomUsageReportLine } from 'src/app/usage-report.service'; +import { AggregationType, CustomUsageReportLine } from 'src/app/usage-report.service'; @Component({ selector: 'app-copilot', @@ -9,5 +9,6 @@ import { CustomUsageReportLine } from 'src/app/usage-report.service'; }) export class CopilotComponent { @Input() data!: CustomUsageReportLine[]; - @Input() currency!: string; + @Input() currency!: 'minutes' | 'cost'; + @Input() grouping!: AggregationType; } diff --git a/src/app/components/usage/copilot/table-workflow-usage/table-copilot-usage.component.ts b/src/app/components/usage/copilot/table-workflow-usage/table-copilot-usage.component.ts index 8427377..1a0b373 100644 --- a/src/app/components/usage/copilot/table-workflow-usage/table-copilot-usage.component.ts +++ b/src/app/components/usage/copilot/table-workflow-usage/table-copilot-usage.component.ts @@ -2,24 +2,10 @@ import { AfterViewInit, Component, Input, OnChanges, ViewChild, ChangeDetectorRe import { MatPaginator } from '@angular/material/paginator'; import { MatTableDataSource } from '@angular/material/table'; import { MatSort } from '@angular/material/sort'; -import { CustomUsageReportLine, UsageReportService } from 'src/app/usage-report.service'; - -interface UsageColumn { - columnDef: string; - header: string; - cell: (element: any) => any; - footer?: () => any; - sticky?: boolean; -} +import { UsageReportService, UserUsageItem, UsageColumn, AggregatedUsageData, AggregationType } from 'src/app/usage-report.service'; +import { CurrencyPipe, DecimalPipe } from '@angular/common'; -interface CopilotUsageItem { - runs: number; - total: number; - cost: number; - pricePerUnit: number; - owner: string; - sticky?: boolean; -} +type Product = 'git_lfs' | 'packages' | 'copilot' | 'actions' | 'codespaces'; @Component({ selector: 'app-table-copilot-usage', @@ -29,11 +15,12 @@ interface CopilotUsageItem { }) export class TableCopilotUsageComponent implements OnChanges, AfterViewInit { columns = [] as UsageColumn[]; + monthColumns = [] as UsageColumn[]; displayedColumns: string[] = []; - @Input() data!: CustomUsageReportLine[]; - @Input() currency!: string; - dataSource: MatTableDataSource = new MatTableDataSource(); // Initialize the dataSource property - tableType = 'owner'; + @Input() currency!: 'minutes' | 'cost'; + @Input() tableType!: AggregationType; + @Input() product: Product | Product[] = 'copilot'; + dataSource: MatTableDataSource = new MatTableDataSource(); @ViewChild(MatPaginator) paginator!: MatPaginator; @ViewChild(MatSort) sort!: MatSort; @@ -46,91 +33,63 @@ export class TableCopilotUsageComponent implements OnChanges, AfterViewInit { } ngOnChanges() { - if (!this.data) { - return; // Avoid processing if data is not available yet - } - this.initializeColumns(); - let usage: CopilotUsageItem[] = []; - let usageItems: CopilotUsageItem[] = (usage as CopilotUsageItem[]); - usageItems = this.data.reduce((acc, line) => { - const item = acc.find(a => { - if (this.tableType === 'owner') { - return a.owner === line.organization; - } - return false; - }); - const month: string = line.date.toLocaleString('default', { month: 'short' }); - if (item) { - if ((item as any)[month]) { - (item as any)[month] += line.value; - } else { - (item as any)[month] = line.value || 0; - } - item.total += line.value; - if (!this.columns.find(c => c.columnDef === month)) { - this.columns.push({ - columnDef: month, - header: month, - cell: (workflowItem: any) => this.currency === 'cost' ? currencyPipe.transform(workflowItem[month]) : decimalPipe.transform(workflowItem[month]), - footer: () => { - if (!this.dataSource?.data) return ''; - const total = this.dataSource.data.reduce((acc, item) => acc + (item as any)[month], 0); - return this.currency === 'cost' ? currencyPipe.transform(total) : decimalPipe.transform(total); - } - }); - } - item.cost += line.quantity * line.pricePerUnit; - item.total += line.quantity; - item.runs++; - } else { - acc.push({ - owner: line.organization, - total: line.quantity, - cost: line.quantity * line.pricePerUnit, - runs: 1, - pricePerUnit: line.pricePerUnit || 0, - [month]: line.value, - }); - } - return acc; - }, [] as CopilotUsageItem[]); + + // Use the service to get aggregated data + this.usageReportService.getAggregatedUsageData(this.tableType, this.product).subscribe((aggregatedData: AggregatedUsageData) => { + // Create month columns using the service helper method + this.monthColumns = this.usageReportService.createMonthColumns(aggregatedData.availableMonths, this.currency, this.dataSource); + this.updateMonthColumnFormatting(); + this.columns = [...this.columns, ...this.monthColumns]; + this.columns = this.columns.sort((a, b) => (!a.date || !b.date) ? 0 : a.date.getTime() - b.date.getTime()); + this.displayedColumns = this.columns.map(c => c.columnDef); + this.dataSource.data = aggregatedData.items as UserUsageItem[]; + + // Mark for check to ensure proper change detection + this.cdr.markForCheck(); + }); + } + + private updateMonthColumnFormatting() { + this.monthColumns.forEach(column => { + // Update cell formatting based on currency + column.cell = (item: any) => + this.currency === 'cost' + ? currencyPipe.transform(item[column.columnDef]) + : decimalPipe.transform(item[column.columnDef]); + + // Update footer formatting + const originalFooter = column.footer; + column.footer = () => { + const total = originalFooter ? originalFooter() : 0; + return this.currency === 'cost' + ? currencyPipe.transform(total) + : decimalPipe.transform(total); + }; - usageItems.forEach((item) => { - this.columns.forEach((column: any) => { - if (!(item as any)[column.columnDef]) { - (item as any)[column.columnDef] = 0; + // Add tooltip and icon functionality for percentage changes + const month = column.columnDef; + column.tooltip = (item: any) => { + const percentChange = item[month + 'PercentChange']; + return percentChange !== undefined ? percentChange.toFixed(2) + '%' : ''; + }; + + column.icon = (item: any) => { + const percentageChanged = item[month + 'PercentChange']; + if (percentageChanged > 0) { + return 'trending_up'; + } else if (percentageChanged < 0) { + return 'trending_down'; + } else { + return 'trending_flat'; } - }); + }; }); - usage = usageItems; - - // Update displayedColumns first - this.displayedColumns = this.columns.map(c => c.columnDef); - - // Then update the data source - this.dataSource = new MatTableDataSource(usage); - - // Apply sort and pagination immediately, without setTimeout - if (this.sort) { - this.dataSource.sort = this.sort; - } - if (this.paginator) { - this.dataSource.paginator = this.paginator; - } - - // Mark for check to ensure proper change detection - this.cdr.markForCheck(); } ngAfterViewInit() { - // We use next tick to avoid the ExpressionChangedAfterItHasBeenCheckedError - Promise.resolve().then(() => { - if (this.dataSource) { - this.dataSource.paginator = this.paginator; - this.dataSource.sort = this.sort; - } - }); + this.dataSource.paginator = this.paginator; + this.dataSource.sort = this.sort; } applyFilter(event: Event) { @@ -144,36 +103,37 @@ export class TableCopilotUsageComponent implements OnChanges, AfterViewInit { initializeColumns() { let columns: UsageColumn[] = []; - if (this.tableType === 'owner') { - columns = [ - { - columnDef: 'owner', - header: 'Owner', - cell: (workflowItem: CopilotUsageItem) => `${workflowItem.owner}`, - sticky: true - } - ]; - if (this.currency === 'minutes') { - columns.push({ - columnDef: 'total', - header: 'Total seats', - cell: (workflowItem: CopilotUsageItem) => decimalPipe.transform(Math.floor(workflowItem.total)), - footer: () => { - if (!this.data) return ''; - return decimalPipe.transform(this.data.reduce((acc, line) => acc += line.value, 0)); - } - }); - } else if (this.currency === 'cost') { - columns.push({ - columnDef: 'cost', - header: 'Total cost', - cell: (workflowItem: CopilotUsageItem) => currencyPipe.transform(workflowItem.cost), - footer: () => { - if (!this.data) return ''; - return currencyPipe.transform(this.data.reduce((acc, line) => acc += line.value, 0)); - } - }); + + // Add the username column for user aggregation + columns = [ + { + columnDef: 'username', + header: 'User', + cell: (item: UserUsageItem) => `${item.username}`, + sticky: true } + ]; + + if (this.currency === 'minutes') { + columns.push({ + columnDef: 'total', + header: 'Total seats', + cell: (item: UserUsageItem) => decimalPipe.transform(Math.floor(item.total)), + footer: () => { + if (!this.dataSource?.data) return ''; + return decimalPipe.transform(this.dataSource.data.reduce((acc, item) => acc + item.total, 0)); + } + }); + } else if (this.currency === 'cost') { + columns.push({ + columnDef: 'cost', + header: 'Total cost', + cell: (item: UserUsageItem) => currencyPipe.transform(item.cost), + footer: () => { + if (!this.dataSource?.data) return ''; + return currencyPipe.transform(this.dataSource.data.reduce((acc, item) => acc + item.cost, 0)); + } + }); } // Important: Clear columns before setting new ones @@ -187,26 +147,5 @@ export class TableCopilotUsageComponent implements OnChanges, AfterViewInit { } } -import { Pipe, PipeTransform } from '@angular/core'; -import { CurrencyPipe, DecimalPipe } from '@angular/common'; - -@Pipe({ - name: 'duration', - standalone: false -}) -export class DurationPipe implements PipeTransform { - transform(minutes: number): string { - const seconds = minutes * 60; - if (seconds < 60) { - return `${seconds} sec`; - } else if (seconds < 3600) { - return `${Math.round(seconds / 60)} min`; - } else { - return `${Math.round(seconds / 3600)} hr`; - } - } - -} - const decimalPipe = new DecimalPipe('en-US'); const currencyPipe = new CurrencyPipe('en-US'); \ No newline at end of file diff --git a/src/app/components/usage/shared-storage/shared-storage.component.ts b/src/app/components/usage/shared-storage/shared-storage.component.ts index efea970..d813049 100644 --- a/src/app/components/usage/shared-storage/shared-storage.component.ts +++ b/src/app/components/usage/shared-storage/shared-storage.component.ts @@ -9,5 +9,5 @@ import { CustomUsageReportLine } from 'src/app/usage-report.service'; }) export class SharedStorageComponent { @Input() data!: CustomUsageReportLine[]; - @Input() currency!: string; + @Input() currency!: 'cost' | 'minutes'; } diff --git a/src/app/components/usage/shared-storage/table-shared-storage/table-shared-storage.component.ts b/src/app/components/usage/shared-storage/table-shared-storage/table-shared-storage.component.ts index c0422e2..cb8b60c 100644 --- a/src/app/components/usage/shared-storage/table-shared-storage/table-shared-storage.component.ts +++ b/src/app/components/usage/shared-storage/table-shared-storage/table-shared-storage.component.ts @@ -1,21 +1,10 @@ -import { Component, Input, Pipe, PipeTransform, ViewChild, OnChanges, AfterViewInit, ChangeDetectorRef } from '@angular/core'; +import { Component, Input, Pipe, PipeTransform, ViewChild, OnChanges, AfterViewInit, ChangeDetectorRef, SimpleChanges } from '@angular/core'; import { MatPaginator } from '@angular/material/paginator'; import { MatTableDataSource } from '@angular/material/table'; import { MatSort } from '@angular/material/sort'; -import { CustomUsageReportLine } from 'src/app/usage-report.service'; +import { AggregatedUsageData, AggregationType, CustomUsageReportLine, Product, RepoUsageItem, SkuUsageItem, UsageColumn, UsageReportService, UserUsageItem, WorkflowUsageItem } from 'src/app/usage-report.service'; import { CurrencyPipe } from '@angular/common'; -type SharedStorageUsageItem = { - repo: string; - count: number; - avgSize: number; - total: number; - totalCost: number; - avgCost: number; - pricePerUnit: number; - costPerDay: number; -}; - @Component({ selector: 'app-table-shared-storage', templateUrl: './table-shared-storage.component.html', @@ -23,101 +12,134 @@ type SharedStorageUsageItem = { standalone: false }) export class TableSharedStorageComponent implements OnChanges, AfterViewInit { - columns: { - columnDef: string; - header: string; - cell: (element: SharedStorageUsageItem) => any; - footer?: () => any; - sticky?: boolean; - }[] = []; displayedColumns: string[] = []; @Input() data!: CustomUsageReportLine[]; - @Input() currency!: string; - dataSource: MatTableDataSource = new MatTableDataSource(); + @Input() tableType: AggregationType = 'repositoryName'; + @Input() currency!: 'minutes' | 'cost'; + @Input() product: Product | Product[] = 'actions'; + dataSource: MatTableDataSource = new MatTableDataSource(); + baseColumns = [] as UsageColumn[]; + monthColumns = [] as UsageColumn[]; + columns = [] as UsageColumn[]; @ViewChild(MatPaginator) paginator!: MatPaginator; @ViewChild(MatSort) sort!: MatSort; - constructor(private cdr: ChangeDetectorRef) { - this.initializeColumns(); - } + constructor( + private usageReportService: UsageReportService, + private cdr: ChangeDetectorRef + ) { } - ngOnChanges() { - if (!this.data) { - return; // Avoid processing if data is not available yet - } + ngOnChanges(changes: SimpleChanges) { + // if (changes['filter'] && !changes['filter'].isFirstChange()) { + // this.dataSource.filter = this.filter.trim().toLowerCase(); - this.initializeColumns(); + // if (this.dataSource.paginator) { + // this.dataSource.paginator.firstPage(); + // } + // return; + // } + this.initializeColumns(); - const workflowUsage = this.data.reduce((acc, line) => { - const workflowEntry = acc.find(a => a.repo === line.repositoryName); - const date = line.date; - const month: string = date.toLocaleString('default', { month: 'short' }); - const cost = line.quantity * line.pricePerUnit; - if (workflowEntry) { - if ((workflowEntry as any)[month] as any) { - (workflowEntry as any)[month] += line.value; - } else { - (workflowEntry as any)[month] = line.value; - } - if (!this.columns.find(c => c.columnDef === month)) { - this.columns.push({ - columnDef: month, - header: month, - cell: (sharedStorageItem: any) => this.currency === 'cost' ? currencyPipe.transform(sharedStorageItem[month]) : fileSizePipe.transform(sharedStorageItem[month]), - footer: () => { - if (!this.dataSource?.data) return ''; - const total = this.dataSource.data.reduce((acc, item) => acc + (item as any)[month], 0); - return this.currency === 'cost' ? currencyPipe.transform(total) : fileSizePipe.transform(total); - } + // Use the service to get aggregated data + this.usageReportService.getAggregatedUsageData(this.tableType, this.product).subscribe((aggregatedData: AggregatedUsageData) => { + // Create month columns using the service helper method + this.monthColumns = this.usageReportService.createMonthColumns(aggregatedData.availableMonths, this.currency, this.dataSource); + // this.updateMonthColumnFormatting(); + + // Combine base columns with month columns and sort them properly + const allColumns = [...this.baseColumns, ...this.monthColumns]; + this.columns = allColumns.sort((a, b) => { + // Keep non-date columns first, then sort date columns chronologically + if (!a.date && !b.date) return 0; + if (!a.date) return -1; + if (!b.date) return 1; + return a.date.getTime() - b.date.getTime(); }); - } - workflowEntry.total += line.quantity; - workflowEntry.totalCost += cost; - workflowEntry.count++; - workflowEntry.costPerDay = cost; - } else { - acc.push({ - repo: line.repositoryName, - total: line.quantity, - count: 1, - totalCost: cost, - avgSize: 0, - avgCost: 0, - [month]: line.value, - pricePerUnit: line.pricePerUnit, - costPerDay: cost + + this.displayedColumns = this.columns.map(c => c.columnDef); + this.dataSource.data = aggregatedData.items; }); - } - return acc; - }, [] as SharedStorageUsageItem[]); - workflowUsage.forEach((sharedStorageItem: SharedStorageUsageItem) => { - this.columns.forEach((column) => { - if (!(sharedStorageItem as any)[column.columnDef]) { - (sharedStorageItem as any)[column.columnDef] = 0; - } - sharedStorageItem.avgSize = sharedStorageItem.total / sharedStorageItem.count; - sharedStorageItem.avgCost = sharedStorageItem.totalCost / sharedStorageItem.count; - }); - }); + return; + // if (!this.data) { + // return; // Avoid processing if data is not available yet + // } + + // this.initializeColumns(); + + // const workflowUsage = this.data.reduce((acc, line) => { + // const workflowEntry = acc.find(a => a.repositoryName === line.repositoryName); + // const date = line.date; + // const month: string = date.toLocaleString('default', { month: 'short' }); + // const cost = line.quantity * line.pricePerUnit; + // if (workflowEntry) { + // if ((workflowEntry as any)[month] as any) { + // (workflowEntry as any)[month] += line.value; + // } else { + // (workflowEntry as any)[month] = line.value; + // } + // if (!this.columns.find(c => c.columnDef === month)) { + // this.columns.push({ + // columnDef: month, + // header: month, + // cell: (sharedStorageItem: WorkflowUsageItem) => this.currency === 'cost' ? currencyPipe.transform(sharedStorageItem[month]) : fileSizePipe.transform(sharedStorageItem[month]), + // footer: () => { + // if (!this.dataSource?.data) return ''; + // const total = this.dataSource.data.reduce((acc, item) => acc + (item as any)[month], 0); + // return this.currency === 'cost' ? currencyPipe.transform(total) : fileSizePipe.transform(total); + // } + // }); + // } + // workflowEntry.total += line.quantity; + // workflowEntry.totalCost += cost; + // workflowEntry.count++; + // workflowEntry.costPerDay = cost; + // } else { + // acc.push({ + // repositoryName: line.repositoryName, + // total: line.quantity, + // count: 1, + // totalCost: cost, + // avgSize: 0, + // avgCost: 0, + // [month]: line.value, + // pricePerUnit: line.pricePerUnit, + // costPerDay: cost, + // avgTime: 0, + // workflowPath: line.workflowPath || '', + // date: line.date + // }); + // } + // return acc; + // }, [] as WorkflowUsageItem[]); - // Update displayedColumns first - this.displayedColumns = this.columns.map(c => c.columnDef); + // workflowUsage.forEach((sharedStorageItem: WorkflowUsageItem) => { + // this.columns.forEach((column) => { + // if (!(sharedStorageItem as any)[column.columnDef]) { + // (sharedStorageItem as any)[column.columnDef] = 0; + // } + // sharedStorageItem.avgSize = sharedStorageItem.total / sharedStorageItem.count; + // sharedStorageItem.avgCost = sharedStorageItem.totalCost / sharedStorageItem.count; + // }); + // }); + + // // Update displayedColumns first + // this.displayedColumns = this.columns.map(c => c.columnDef); - // Then update the data source - this.dataSource = new MatTableDataSource(workflowUsage); + // // Then update the data source + // this.dataSource = new MatTableDataSource(workflowUsage); - // Apply sort and pagination immediately, without setTimeout - if (this.sort) { - this.dataSource.sort = this.sort; - } - if (this.paginator) { - this.dataSource.paginator = this.paginator; - } + // // Apply sort and pagination immediately, without setTimeout + // if (this.sort) { + // this.dataSource.sort = this.sort; + // } + // if (this.paginator) { + // this.dataSource.paginator = this.paginator; + // } - // Mark for check to ensure proper change detection - this.cdr.markForCheck(); + // // Mark for check to ensure proper change detection + // this.cdr.markForCheck(); } ngAfterViewInit() { @@ -134,21 +156,21 @@ export class TableSharedStorageComponent implements OnChanges, AfterViewInit { const columns: { columnDef: string, header: string, - cell: (sharedStorageItem: SharedStorageUsageItem) => any, + cell: (sharedStorageItem: WorkflowUsageItem) => any, footer?: () => any, sticky?: boolean }[] = [ { - columnDef: 'repo', + columnDef: 'repositoryName', header: 'Repository', - cell: (sharedStorageItem: any) => sharedStorageItem.repo, + cell: (sharedStorageItem: WorkflowUsageItem) => sharedStorageItem.repositoryName, footer: () => 'Total', sticky: true }, { columnDef: 'count', header: 'Count', - cell: (sharedStorageItem: any) => sharedStorageItem.count, + cell: (sharedStorageItem: WorkflowUsageItem) => sharedStorageItem.count, footer: () => { if (!this.dataSource?.data) return ''; return this.dataSource.data.reduce((acc, line) => acc + line.count, 0); @@ -160,16 +182,16 @@ export class TableSharedStorageComponent implements OnChanges, AfterViewInit { { columnDef: 'total', header: 'Total Cost', - cell: (sharedStorageItem: any) => currencyPipe.transform(sharedStorageItem.totalCost), + cell: (sharedStorageItem: WorkflowUsageItem) => currencyPipe.transform(sharedStorageItem.total), footer: () => { if (!this.dataSource?.data) return ''; - return currencyPipe.transform(this.dataSource.data.reduce((acc, line) => acc + line.totalCost, 0)); + return currencyPipe.transform(this.dataSource.data.reduce((acc, line) => acc + line.total, 0)); } }, { columnDef: 'costPerDay', header: 'Cost Per Day', - cell: (sharedStorageItem: SharedStorageUsageItem) => currencyPipe.transform(sharedStorageItem.costPerDay), + cell: (sharedStorageItem: WorkflowUsageItem) => currencyPipe.transform(sharedStorageItem.costPerDay), footer: () => { if (!this.dataSource?.data) return ''; return currencyPipe.transform(this.dataSource.data.reduce((acc, line) => acc + line.costPerDay, 0)); @@ -181,7 +203,7 @@ export class TableSharedStorageComponent implements OnChanges, AfterViewInit { { columnDef: 'avgSize', header: 'Average Size', - cell: (sharedStorageItem: any) => fileSizePipe.transform(sharedStorageItem.avgSize), + cell: (sharedStorageItem: WorkflowUsageItem) => fileSizePipe.transform(sharedStorageItem.avgSize), footer: () => { if (!this.dataSource?.data || this.dataSource.data.length === 0) return ''; return fileSizePipe.transform(this.dataSource.data.reduce((acc, line) => acc + line.avgSize, 0) / this.dataSource.data.length); @@ -190,7 +212,7 @@ export class TableSharedStorageComponent implements OnChanges, AfterViewInit { { columnDef: 'total', header: 'Total', - cell: (sharedStorageItem: any) => fileSizePipe.transform(sharedStorageItem.total), + cell: (sharedStorageItem: WorkflowUsageItem) => fileSizePipe.transform(sharedStorageItem.total), footer: () => { if (!this.dataSource?.data) return ''; return fileSizePipe.transform(this.dataSource.data.reduce((acc, line) => acc + line.total, 0)); @@ -198,7 +220,8 @@ export class TableSharedStorageComponent implements OnChanges, AfterViewInit { } ); } - this.columns = columns; + this.baseColumns = columns; + this.monthColumns = []; // Reset month columns // Update displayedColumns immediately after updating columns this.displayedColumns = this.columns.map(c => c.columnDef); } diff --git a/src/app/components/usage/usage.component.html b/src/app/components/usage/usage.component.html index 285a1fd..3a70054 100644 --- a/src/app/components/usage/usage.component.html +++ b/src/app/components/usage/usage.component.html @@ -1,24 +1,53 @@ -
+
+

GitHub
Usage Report Viewer

-
-
- -
+
+ +
- - + + + +

Premium Requests

- + + Grouping + + Cost Center + Organization + Repository + User + Runner + Workflow + Workflow Path + + + Choose a date range @@ -31,21 +60,21 @@

Premium Requests

close
- - Workflow - - - @for (option of _filteredWorkflows | async; track option) { - {{option}} - } - - @if (workflowControl.value) { - - } + + Filter + + + + + +
@@ -55,10 +84,6 @@

Premium Requests

Cost
- @@ -68,7 +93,8 @@

Premium Requests

- +
@@ -98,7 +124,8 @@

Premium Requests

- +
@@ -128,4 +155,4 @@

Premium Requests

- + \ No newline at end of file diff --git a/src/app/components/usage/usage.component.scss b/src/app/components/usage/usage.component.scss index d6267cc..438742e 100644 --- a/src/app/components/usage/usage.component.scss +++ b/src/app/components/usage/usage.component.scss @@ -10,10 +10,13 @@ form { top: 0; z-index: 999; display: flex; + align-items: center; + background: var(--mat-sys-background); + box-sizing: border-box; + padding: 10px 0; mat-form-field { margin-right: 15px; } - margin-top: 15px;; @media screen and (max-width: 767px) { flex-wrap: wrap; } diff --git a/src/app/components/usage/usage.component.ts b/src/app/components/usage/usage.component.ts index 4952da4..cdcba70 100644 --- a/src/app/components/usage/usage.component.ts +++ b/src/app/components/usage/usage.component.ts @@ -1,11 +1,12 @@ -import { OnInit, ChangeDetectorRef, Component, OnDestroy } from '@angular/core'; +import { OnInit, ChangeDetectorRef, Component, OnDestroy, inject, Input } from '@angular/core'; import { FormControl, FormGroup } from '@angular/forms'; import { UsageReport } from 'github-usage-report/src/types'; import { Observable, Subscription, debounceTime, map, startWith } from 'rxjs'; -import { CustomUsageReportLine, UsageReportService } from 'src/app/usage-report.service'; +import { AggregationType, CustomUsageReportLine, UsageReportService } from 'src/app/usage-report.service'; import { DialogBillingNavigateComponent } from './dialog-billing-navigate'; import { MatDialog } from '@angular/material/dialog'; import { ModelUsageReport } from 'github-usage-report'; +import { MatBottomSheet, MatBottomSheetRef } from '@angular/material/bottom-sheet'; @Component({ selector: 'app-usage', @@ -14,6 +15,7 @@ import { ModelUsageReport } from 'github-usage-report'; standalone: false }) export class UsageComponent implements OnInit, OnDestroy { + @Input() theme!: 'light-theme' | 'dark-theme'; usage!: UsageReport; usageCopilotPremiumRequests!: ModelUsageReport; usageLines = {} as { @@ -28,15 +30,15 @@ export class UsageComponent implements OnInit, OnDestroy { }); minDate!: Date; maxDate!: Date; - workflows: string[] = []; workflow!: string; - _filteredWorkflows!: Observable; - workflowControl = new FormControl(''); status: string = 'Usage Report'; progress: number | null = null; subscriptions: Subscription[] = []; + filter: string = ''; currency: 'minutes' | 'cost' = 'cost'; tabSelected: 'shared-storage' | 'copilot' | 'actions' = 'actions'; + groupingControl = new FormControl('sku' as AggregationType); + private _bottomSheet = inject(MatBottomSheet); constructor( private usageReportService: UsageReportService, @@ -58,19 +60,6 @@ export class UsageComponent implements OnInit, OnDestroy { }) ); - this.subscriptions.push( - this.workflowControl.valueChanges.subscribe(value => { - if (!value || value === '') value = ''; - this.usageReportService.applyFilter({ - workflow: value, - }); - }) - ); - this._filteredWorkflows = this.workflowControl.valueChanges.pipe( - startWith(''), - map(value => this._filterWorkflows(value || '')), - ); - this.subscriptions.push( this.usageReportService.getUsageFilteredByProduct('actions').subscribe((usageLines) => { this.usageLines.actions = usageLines; @@ -83,10 +72,7 @@ export class UsageComponent implements OnInit, OnDestroy { }), this.usageReportService.getUsageFilteredByProduct('codespaces').subscribe((usageLines) => { this.usageLines.codespaces = usageLines; - }), - this.usageReportService.getWorkflowsFiltered().subscribe((workflows) => { - this.workflows = workflows; - }), + }) ); } @@ -129,11 +115,6 @@ export class UsageComponent implements OnInit, OnDestroy { this.cdr.detectChanges(); } - private _filterWorkflows(workflow: string): string[] { - const filterValue = workflow.toLowerCase(); - return this.workflows.filter(option => option.toLowerCase().includes(filterValue)); - } - navigateToBilling() { const dialogRef = this.dialog.open(DialogBillingNavigateComponent); @@ -148,6 +129,11 @@ export class UsageComponent implements OnInit, OnDestroy { }); } + applyFilter(event: Event) { + const filterValue = (event.target as HTMLInputElement).value; + this.filter = filterValue.trim().toLowerCase(); + } + changeCurrency(currency: string) { this.currency = currency as 'minutes' | 'cost'; this.usageReportService.setValueType(this.currency); @@ -193,4 +179,76 @@ export class UsageComponent implements OnInit, OnDestroy { a.click(); (a as any).parentNode.removeChild(a); } + + openBottomSheet(): void { + this._bottomSheet.open(BottomSheetOverviewExampleSheetComponent, { + autoFocus: false + }); + } + + reloadPage() { + window.location.reload(); + } } + +@Component({ + selector: 'app-bottom-sheet-overview-example-sheet', + standalone: false, + // templateUrl: 'bottom-sheet-overview-example-sheet.html', + template: ` + + Workflow + + + @for (option of _filteredWorkflows | async; track option) { + {{option}} + } + + @if (workflowControl.value) { + + } + + ` +}) +export class BottomSheetOverviewExampleSheetComponent { + @Input() tabSelected: 'shared-storage' | 'copilot' | 'actions' = 'actions'; + private _bottomSheetRef = + inject>(MatBottomSheetRef); + + workflowControl = new FormControl(''); + _filteredWorkflows!: Observable; + workflows: string[] = []; + + constructor ( + private usageReportService: UsageReportService, + ) {} + + ngOnInit() { + this.usageReportService.getWorkflowsFiltered().subscribe((workflows) => { + this.workflows = workflows; + }); + this.workflowControl.valueChanges.subscribe(value => { + if (!value || value === '') value = ''; + this.usageReportService.applyFilter({ + workflow: value, + }); + }) + this._filteredWorkflows = this.workflowControl.valueChanges.pipe( + startWith(''), + map(value => this._filterWorkflows(value || '')), + ); + } + + private _filterWorkflows(workflow: string): string[] { + const filterValue = workflow.toLowerCase(); + return this.workflows.filter(option => option.toLowerCase().includes(filterValue)); + } + + openLink(event: MouseEvent): void { + this._bottomSheetRef.dismiss(); + event.preventDefault(); + } +} \ No newline at end of file diff --git a/src/app/usage-report.service.ts b/src/app/usage-report.service.ts index e28fd47..e1d27b9 100644 --- a/src/app/usage-report.service.ts +++ b/src/app/usage-report.service.ts @@ -1,7 +1,9 @@ import { TitleCasePipe } from '@angular/common'; import { Injectable } from '@angular/core'; import { ModelUsageReport, readGithubUsageReport, readModelUsageReport, UsageReport, UsageReportLine } from 'github-usage-report'; -import { BehaviorSubject, Observable, map } from 'rxjs'; +import { BehaviorSubject, Observable, map, tap } from 'rxjs'; + +const titlecasePipe = new TitleCasePipe(); interface Filter { startDate: Date; @@ -10,8 +12,6 @@ interface Filter { sku: string; } -type Product = 'git_lfs' | 'packages' | 'copilot' | 'actions' | 'codespaces'; - export interface CustomUsageReportLine extends UsageReportLine { value: number; } @@ -20,6 +20,88 @@ export interface CustomUsageReport extends UsageReport { lines: CustomUsageReportLine[]; } +export interface SharedStorageUsageItem { + repositoryName: string; + count: number; + avgSize: number; + total: number; + totalCost: number; + avgCost: number; + pricePerUnit: number; + costPerDay: number; +} + +export interface WorkflowUsageItem { + workflowName: string; + workflowPath: string; + costCenterName: string; + organization: string; + avgTime: number; + avgCost: number; + runs: number; + repositoryName: string; + total: number; + cost: number; + pricePerUnit: number; + sku: string; + username: string; + [month: string]: any; // For dynamic month columns + avgSize: number; // For shared storage + costPerDay: number; // For shared storage + count: number; // For shared storage +} + +export interface RepoUsageItem { + avgTime: number; + avgCost: number; + repositoryName: string; + runs: number; + total: number; + cost: number; + sku: string; + [month: string]: any; // For dynamic month columns +} + +export interface SkuUsageItem { + avgTime: number; + avgCost: number; + sku: string; + runs: number; + total: number; + cost: number; + [month: string]: any; // For dynamic month columns +} + +export interface UserUsageItem { + avgTime: number; + avgCost: number; + username: string; + runs: number; + total: number; + cost: number; + [month: string]: any; // For dynamic month columns +} + +export interface UsageColumn { + sticky?: boolean; + columnDef: string; + header: string; + cell: (element: any) => any; + footer?: () => any; + tooltip?: (element: any) => any; + icon?: (element: any) => string; + date?: Date; +} + +export interface AggregatedUsageData { + items: WorkflowUsageItem[] | RepoUsageItem[] | SkuUsageItem[] | UserUsageItem[]; + availableMonths: string[]; // Just the month keys, let components build columns + dateRange: { start: Date; end: Date }; +} + +export type AggregationType = 'workflow' | 'sku' | 'organization' | 'repositoryName' | 'costCenterName' | 'username' | 'workflowPath'; +export type Product = 'git_lfs' | 'packages' | 'copilot' | 'actions' | 'codespaces'; + @Injectable({ providedIn: 'root' }) @@ -30,6 +112,7 @@ export class UsageReportService { usageReportCopilotPremiumRequests!: ModelUsageReport; usageReportFiltered: BehaviorSubject = new BehaviorSubject([]); usageReportFilteredProduct: { [key: string]: Observable } = {}; + filters: Filter = { startDate: new Date(), endDate: new Date(), @@ -147,6 +230,7 @@ export class UsageReportService { this.usageReportPremiumRequestsData = usageReportData; await readModelUsageReport(this.usageReportPremiumRequestsData).then((report) => { this.usageReportCopilotPremiumRequests = report; + cb?.(this.usageReport, 100); }); return this.usageReportCopilotPremiumRequests; } @@ -154,6 +238,7 @@ export class UsageReportService { async setUsageReportData(usageReportData: string, cb?: (usageReport: CustomUsageReport, percent: number) => void): Promise { this.usageReportData = usageReportData; this.usageReport = await readGithubUsageReport(this.usageReportData) as CustomUsageReport; + cb?.(this.usageReport, 100); this.filters.startDate = this.usageReport.startDate; this.filters.endDate = this.usageReport.endDate; @@ -184,7 +269,6 @@ export class UsageReportService { } }); this.setValueType(this.valueType.value); - console.log('Usage Report Loaded:', this.usageReport); return this.usageReport; } @@ -195,6 +279,7 @@ export class UsageReportService { sku?: string, }): void { Object.assign(this.filters, filter); + let filtered = this.usageReport.lines; if (this.filters.sku) { filtered = filtered.filter(line => line.sku === this.filters.sku); @@ -217,7 +302,15 @@ export class UsageReportService { getUsageFilteredByProduct(product: Product | Product[]): Observable { const _products = Array.isArray(product) ? product : [product]; return this.getUsageReportFiltered().pipe( - map(lines => lines.filter(line => _products.some(p => line.product.includes(p)))), + map(lines => lines.filter(line => _products.some(p => { + const filtered = line.product.includes(p); + if (product === 'actions') { + if (line.sku === 'actions_storage') { + return false; // Exclude storage from actions + } + } + return filtered; + }))), ); } @@ -257,6 +350,182 @@ export class UsageReportService { } return formatted; } -} -const titlecasePipe = new TitleCasePipe(); \ No newline at end of file + calculatePercentageChange(oldValue: number, newValue: number): number { + return (oldValue === 0) ? 0 : ((newValue - oldValue) / oldValue) * 100; + } + + getAggregatedUsageData(aggregationType: AggregationType, product?: Product | Product[]): Observable { + const productFilter = product || 'actions'; + + return this.getUsageFilteredByProduct(productFilter).pipe( + map(data => { + // Calculate aggregated data directly without caching + return this.aggregateUsageData(data, aggregationType); + }) + ); + } + + private aggregateUsageData(data: CustomUsageReportLine[], aggregationType: AggregationType): AggregatedUsageData { + const availableMonths: string[] = []; + const dateRange = { start: new Date(), end: new Date() }; + + const usageItems = data.reduce((acc, line) => { + const item = acc.find(a => { + switch (aggregationType) { + case 'sku': + return (a as WorkflowUsageItem).sku === this.formatSku(line.sku); + case 'organization': + return (a as WorkflowUsageItem).organization === line.organization; + case 'costCenterName': + return (a as WorkflowUsageItem).costCenterName === line.costCenterName; + case 'repositoryName': + return (a as WorkflowUsageItem).repositoryName === line.repositoryName; + case 'workflow': + return (a as WorkflowUsageItem).workflowName === line.workflowName; + case 'workflowPath': + return (a as WorkflowUsageItem).workflowPath === line.workflowPath; + case 'username': + return (a as WorkflowUsageItem).username === line.username; + default: + return false; + } + }); + + const month: string = line.date.toLocaleString('default', { month: 'short', year: '2-digit' }); + + // Track available months + if (!availableMonths.includes(month)) { + availableMonths.push(month); + } + + // Track date range + if (line.date < dateRange.start) dateRange.start = line.date; + if (line.date > dateRange.end) dateRange.end = line.date; + + if (item) { + if ((item as any)[month]) { + (item as any)[month] += line.value; + } else { + (item as any)[month] = line.value || 0; + } + + item.cost += line.quantity * line.pricePerUnit; + item.total += line.quantity; + item.runs++; + } else { + const newItem: WorkflowUsageItem = { + total: line.quantity, + cost: line.quantity * line.pricePerUnit, + runs: 1, + pricePerUnit: line.pricePerUnit || 0, + avgCost: line.quantity * line.pricePerUnit, + avgTime: line.value, + [month]: line.value, + workflowName: line.workflowName, + workflowPath: line.workflowPath, + costCenterName: line.costCenterName, + organization: line.organization, + repositoryName: line.repositoryName, + sku: this.formatSku(line.sku), + username: line.username, + // shared-storage + avgSize: line.value, + costPerDay: line.quantity * line.pricePerUnit, + count: 1, // For shared storage + }; + + // Set aggregation-specific properties + switch (aggregationType) { + case 'workflow': + newItem.workflowName = line.workflowName; + newItem.repositoryName = line.repositoryName; + newItem.sku = this.formatSku(line.sku); + newItem.username = line.username; + break; + case 'workflowPath': + newItem.workflowName = line.workflowName; + newItem.workflowPath = line.workflowPath; + newItem.costCenterName = line.costCenterName; + newItem.organization = line.organization; + newItem.sku = this.formatSku(line.sku); + newItem.username = line.username; + break; + case 'costCenterName': + newItem.costCenterName = line.costCenterName; + newItem.organization = line.organization; + newItem.sku = this.formatSku(line.sku); + newItem.username = line.username; + break; + case 'organization': + newItem.organization = line.organization; + newItem.sku = this.formatSku(line.sku); + newItem.username = line.username; + break; + case 'repositoryName': + newItem.repositoryName = line.repositoryName; + newItem.sku = this.formatSku(line.sku); + break; + case 'sku': + newItem.sku = this.formatSku(line.sku); + break; + case 'username': + newItem.username = line.username; + break; + } + + acc.push(newItem); + } + return acc; + }, [] as any[]); + + // Calculate averages and ensure all items have all month properties + usageItems.forEach((item) => { + availableMonths.forEach((month: string) => { + if (!(item as any)[month]) { + (item as any)[month] = 0; + } + + // Calculate percentage change from previous month if needed + const monthIndex = availableMonths.indexOf(month); + if (monthIndex > 0) { + const previousMonth = availableMonths[monthIndex - 1]; + const lastMonthValue = (item as any)[previousMonth] || 0; + const percentageChanged = this.calculatePercentageChange(lastMonthValue, (item as any)[month]); + (item as any)[month + 'PercentChange'] = percentageChanged; + } + }); + + item.avgTime = item.runs > 0 ? item.total / item.runs : 0; + item.avgCost = item.runs > 0 ? item.cost / item.runs : 0; + }); + + // Sort months chronologically + availableMonths.sort((a, b) => { + const dateA = new Date(a); + const dateB = new Date(b); + return dateA.getTime() - dateB.getTime(); + }); + + return { + items: usageItems, + availableMonths: availableMonths, + dateRange: dateRange + }; + } + + // Helper method for components to get month columns + createMonthColumns(availableMonths: string[], currency: 'minutes' | 'cost', dataSource?: any): UsageColumn[] { + return availableMonths.map(month => ({ + columnDef: month, + header: month, + cell: (item: any) => item[month] || 0, + footer: () => { + if (!dataSource?.data) return ''; + const total = dataSource.data.reduce((acc: number, item: any) => acc + (item[month] || 0), 0); + return total; + }, + date: new Date(month) + })); + } +} \ No newline at end of file diff --git a/src/material.module.ts b/src/material.module.ts index a796d50..e522b44 100644 --- a/src/material.module.ts +++ b/src/material.module.ts @@ -20,6 +20,7 @@ import { MatTabsModule } from '@angular/material/tabs'; import { MatListModule } from '@angular/material/list'; import { MatTooltipModule } from '@angular/material/tooltip'; import { MatMenuModule } from '@angular/material/menu'; +import { MatBottomSheetModule } from '@angular/material/bottom-sheet'; @NgModule({ exports: [ @@ -28,7 +29,6 @@ import { MatMenuModule } from '@angular/material/menu'; MatTableModule, MatSortModule, MatPaginatorModule, - MatButtonModule, MatTableModule, MatPaginatorModule, MatSortModule, @@ -47,7 +47,8 @@ import { MatMenuModule } from '@angular/material/menu'; MatTabsModule, MatListModule, MatTooltipModule, - MatMenuModule + MatMenuModule, + MatBottomSheetModule ] }) export class MaterialModule { } diff --git a/src/styles.scss b/src/styles.scss index 519709a..b4e5deb 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -911,6 +911,7 @@ h1 { .logo { margin-top: 3rem; + width: auto; } .hide { @@ -950,4 +951,8 @@ mat-cell { mat-icon { vertical-align: bottom !important; } +} + +.spacer { + flex: 1 1 auto; } \ No newline at end of file