Initial dashboard work

This commit is contained in:
Matthew Ross 2020-06-11 15:47:43 -04:00
parent 61ab341e33
commit 2bc585f205
13 changed files with 566 additions and 124 deletions

View File

@ -0,0 +1,166 @@
<?php
use RedBeanPHP\R;
class Dashboard extends BaseController {
public function getMyBoardInfo($request, $response) {
$status = $this->secureRoute($request, $response, SecurityLevel::USER);
if ($status !== 200) {
return $this->jsonResponse($response, $status);
}
$boards = $this->loadAllBoards($request);
if (!count($boards)) {
$this->apiJson->addAlert('info', $this->strings->api_noBoards);
return $this->jsonResponse($response);
}
$this->apiJson->setSuccess();
$this->apiJson->addData($this->convertBoardData($boards));
return $this->jsonResponse($response);
}
public function getMyTaskInfo($request, $response) {
$status = $this->secureRoute($request, $response, SecurityLevel::USER);
if ($status !== 200) {
return $this->jsonResponse($response, $status);
}
$boards = $this->loadAllBoards($request);
if (!count($boards)) {
$this->apiJson->addAlert('info', $this->strings->api_noBoards);
return $this->jsonResponse($response);
}
$this->apiJson->setSuccess();
$userId = Auth::GetUserId($request);
$this->apiJson->addData($this->convertTaskData($boards, $userId));
return $this->jsonResponse($response);
}
private function convertBoardData($boards) {
$retVal = [];
foreach($boards as $board) {
if ($board["is_active"] !== '1') {
continue;
}
$retVal[] = (object)array(
"id" => $board["id"],
"name" => $board["name"],
"columns" => [],
"categories" => []
);
$index = count($retVal) - 1;
foreach($board["ownColumn"] as $column) {
$retVal[$index]->columns[] = (object)array(
"name" => $column["name"],
"tasks" => count($column["ownTask"])
);
}
foreach($board["ownCategory"] as $category) {
$retVal[$index]->categories[] = (object)array(
"name" => $category["name"],
"tasks" => count($category["sharedTask"])
);
}
}
return $retVal;
}
private function convertTaskData($boards, $userId) {
$retVal = [];
foreach($boards as $board) {
foreach($board["ownColumn"] as $column) {
foreach($column["ownTask"] as $task) {
$isMine = false;
foreach($task["sharedUser"] as $assignee) {
if ($assignee["id"] === (string)$userId) {
$isMine = true;
}
}
if (!$isMine) {
continue;
}
$attachments = R::exec(
"SELECT COUNT(id) AS num FROM attachment WHERE task_id = ?",
[ $task["id"] ]
);
$comments = R::exec(
"SELECT COUNT(id) AS num FROM comment WHERE task_id = ?",
[ $task["id"] ]
);
$retVal[] = (object)array(
"board" => $board["name"],
"board_id" => $board["id"],
"title" => $task["title"],
"color" => $task["color"],
"column" => $column["name"],
"date_due" => $task["due_date"],
"points" => $task["points"],
"attachments" => $attachments,
"comments" => $comments
);
}
}
}
return $retVal;
}
private function loadAllBoards($request) {
$boards = [];
$boardBeans = R::findAll('board');
if (count($boardBeans)) {
foreach ($boardBeans as $bean) {
if (Auth::HasBoardAccess($request, $bean->id)) {
$this->cleanBoard($bean);
$boards[] = $bean;
}
}
}
return R::exportAll($boards);
}
private function cleanBoard(&$board) {
foreach ($board->sharedUserList as $user) {
$user = $this->cleanUser($user);
}
foreach ($board->xownColumnList as $column) {
foreach ($column->xownTaskList as $task) {
foreach ($task->sharedUserList as $user) {
$user = $this->cleanUser($user);
}
}
}
}
private function cleanUser($user) {
unset($user->password_hash);
unset($user->active_token);
return $user;
}
}

View File

@ -106,6 +106,9 @@ $app->post('/logout', 'Auth:logout'); // Unsecured (clears JWT)
$app->post('/authenticate', 'Auth:authenticate'); // Unsecured (checks JWT) $app->post('/authenticate', 'Auth:authenticate'); // Unsecured (checks JWT)
$app->post('/refresh', 'Auth:refreshToken'); // Unsecured (checks and updates JWT) $app->post('/refresh', 'Auth:refreshToken'); // Unsecured (checks and updates JWT)
$app->get('/dashboard/boards', 'Dashboard:getMyBoardInfo'); // User (by board access)
$app->get('/dashboard/tasks', 'Dashboard:getMyTaskInfo'); // User (by board access)
$app->run(); $app->run();
R::close(); R::close();

View File

@ -1,134 +1,72 @@
<tb-top-nav page-name="Dashboard"></tb-top-nav> <tb-top-nav page-name="{{ pageName }}"></tb-top-nav>
<div class="dashboard"> <div class="dashboard">
<section> <tb-my-items [boardsLoading]="boardsLoading"
<h2>Boards and Tasks</h2> [boards]="boards" [boardsMessage]="boardsMessage"
[tasksLoading]="tasksLoading" [tasks]="tasks"
<div class="row"> [tasksMessage]="tasksMessage"
<h3>My Boards</h3> [strings]="strings"></tb-my-items>
<table class="alternating">
<thead>
<tr>
<th>Board</th>
<th>Columns</th>
<th>Categories</th>
</tr>
</thead>
<tbody>
<tr>
<td><a href="#">Personal Projects</a></td>
<td>
To Do <span class="badge" title="Tasks in Column">8</span>
Doing <span class="badge" title="Tasks in Column">3</span>
Done <span class="badge" title="Tasks in Column">0</span>
</td>
<td>
List <span class="badge" title="Tasks in Category">3</span>
Thing 1 <span class="badge" title="Tasks in Category">4</span>
Thing 2 <span class="badge" title="Tasks in Category">4</span>
</td>
</tr>
<tr>
<td><a href="#">TaskBoard</a></td>
<td>
Backlog <span class="badge" title="Tasks in Column">23</span>
Ready <span class="badge" title="Tasks in Column">5</span>
In Work <span class="badge" title="Tasks in Column">3</span>
Test <span class="badge" title="Tasks in Column">2</span>
Done <span class="badge" title="Tasks in Column">18</span>
</td>
<td>
Front-End <span class="badge" title="Tasks in Category">19</span>
Back-End <span class="badge" title="Tasks in Category">28</span>
Test <span class="badge" title="Tasks in Category">2</span>
</td>
</tr>
</tbody>
</table>
</div>
<div class="row">
<h3>My Tasks</h3>
<table class="alternating">
<thead>
<tr>
<th>Board</th>
<th>Task</th>
<th>Details</th>
</tr>
</thead>
<tbody>
<tr>
<td><a href="#">TaskBoard</a></td>
<td>
<a href="#">An Important Task</a>
<span class="badge" title="Task Color"
style="background-color:#debee8">&nbsp;</span>
</td>
<td>
<span class="details">Column: <em>In Work</em></span>
<span class="details">Due: <em>12/31/2016</em></span>
<span class="details">Points: <em>8</em></span>
<span class="details">Attachments: <em>2</em></span>
<span class="details">Comments: <em>5</em></span>
</td>
</tr>
<tr>
<td><a href="#">Personal Projects</a></td>
<td>
<a href="#">Make a List</a>
<span class="badge" title="Task Color"
style="background-color:#bee7f4">&nbsp;</span>
</td>
<td>
<span class="details">Column: <em>To Do</em></span>
<span class="details">Comments: <em>2</em></span>
</td>
</tr>
</tbody>
</table>
</div>
</section>
<section> <section>
<h2>Analytics</h2> <h2>{{ strings['dashboard_analytics'] }}</h2>
<div class="row"> <div class="row">
<select> <select [(ngModel)]="analyticsBoardId" (change)="updateAnalytics()">
<option>Select Board...</option> <option [ngValue]="null">
{{ strings['boards_selectBoard'] }}...
</option>
<option *ngFor="let board of boards">
{{ board.name }}
</option>
</select> </select>
</div> </div>
<div class="row"> <div class="row" *ngIf="analyticsBoardId">
<h3>Task Burndown</h3> <h3>Task Burndown</h3>
<label class="inline">Start Date: <input type="date"></label> <label class="inline">
<label class="inline">End Date: <input type="date"></label> Start Date:
<input type="date" [(ngModel)]="burndownDates.start"
(change)="validateDates()">
</label>
<tb-charts chart-name="chartBurndown" chart-type="line" <label class="inline">
series="29,26,21,18,13,8,3" End Date:
labels="12/31/2015,1/1/2016,1/2/2016,1/3/2016,1/4/2016,1/5/2016,1/6/2016" <input type="date" [(ngModel)]="burndownDates.end"
table-head="Date"></tb-charts> (change)="validateDates()">
</label>
<div *ngIf="datesError.length" class="error">{{ datesError }}</div>
<div *ngIf="!showBurndown">Select dates to display burndown chart.</div>
<tb-charts *ngIf="showBurndown"
chart-name="chartBurndown" chart-type="line"
series="29,26,21,18,13,8,3"
labels="12/31/2015,1/1/2016,1/2/2016,1/3/2016,1/4/2016,1/5/2016,1/6/2016"
table-head="Date"></tb-charts>
</div> </div>
<div class="row"> <div class="row" *ngIf="analyticsBoardId">
<div class="half-page"> <div class="half-page">
<h3>Task Distribution by User</h3> <h3>Task Distribution by User</h3>
<tb-charts chart-name="chartByUser" series="7,13,8,5" <tb-charts chart-name="chartByUser" series="7,13,8,5"
labels="admin,tester,user,another" labels="admin,tester,user,another"
table-head="User"></tb-charts> table-head="User"></tb-charts>
</div> </div>
<div class="half-page"> <div class="half-page">
<h3>Task Distribution by Column</h3> <h3>Task Distribution by Column</h3>
<tb-charts chart-name="chartByColumn" series="18,3,7" <tb-charts chart-name="chartByColumn" series="18,3,7"
labels="To Do,Doing,Done" labels="To Do,Doing,Done"
table-head="Column"></tb-charts> table-head="Column"></tb-charts>
</div> </div>
</div> </div>
<div class="row"> <div class="row" *ngIf="analyticsBoardId">
<tb-calendar board-id="1"></tb-calendar> <tb-calendar board-id="1"></tb-calendar>
</div> </div>
</section> </section>

View File

@ -1,17 +1,123 @@
import { Component } from '@angular/core'; import { Component, OnInit, OnDestroy } from '@angular/core';
import { Title } from '@angular/platform-browser'; import { Title } from '@angular/platform-browser';
// import { Charts } from './charts/charts.component'; import { DashboardService } from './dashboard.service';
// import { Calendar } from './calendar/calendar.component'; import { StringsService } from '../shared/services';
interface BurndownDates {
start: string;
end: string;
startDate: Date;
endDate: Date;
}
/* istanbul ignore next */
@Component({ @Component({
selector: 'tb-dashboard', selector: 'tb-dashboard',
templateUrl: './dashboard.component.html' templateUrl: './dashboard.component.html'
}) })
export class DashboardComponent { export class DashboardComponent implements OnInit, OnDestroy {
constructor(public title: Title) { private subs: any[];
title.setTitle('TaskBoard - Dashboard');
public boards: any;
public boardsLoading: boolean;
public boardsMessage: string;
public tasks: any;
public tasksLoading: boolean;
public tasksMessage: string;
public strings: any;
public pageName: string;
public analyticsBoardId: number;
public burndownDates: BurndownDates;
public datesError: string;
get showBurndown() {
return this.burndownDates.start &&
this.burndownDates.end && !this.datesError.length;
}
constructor(public title: Title,
private service: DashboardService,
public stringsService: StringsService) {
this.subs = [];
this.boardsLoading = true;
this.tasksLoading = true;
this.burndownDates = {
start: null,
end: null,
startDate: null,
endDate: null
};
this.datesError = '';
this.subs.push(
stringsService.stringsChanged.subscribe(newStrings => {
this.strings = newStrings;
title.setTitle('TaskBoard - ' + this.strings.dashboard);
this.pageName = this.strings.dashboard;
})
);
}
ngOnInit() {
this.service.getBoardInfo().subscribe(res => {
this.boards = res.data[1];
if (res.status === 'failure') {
this.boardsMessage = res.alerts[0].text;
}
this.boardsLoading = false;
});
this.service.getTaskInfo().subscribe(res => {
this.tasks = res.data[1];
if (res.status === 'failure') {
this.tasksMessage = res.alerts[0].text;
}
this.tasksLoading = false;
})
}
ngOnDestroy() {
this.subs.forEach(sub => sub.unsubscribe());
}
validateDates() {
if (this.burndownDates.start === null || this.burndownDates.end === null) {
return;
}
this.datesError = '';
this.burndownDates.startDate = new Date(this.burndownDates.start);
this.burndownDates.endDate = new Date(this.burndownDates.end);
const start = this.burndownDates.startDate.valueOf();
const end = this.burndownDates.endDate.valueOf();
const now = new Date().valueOf();
if (start > end) {
this.datesError = 'End date must be after start date.';
}
if (start > now) {
this.datesError += ' Start date must be today or earlier.';
}
if (end > now) {
this.datesError += ' End date must be today or earlier.';
}
}
updateAnalytics() {
} }
} }

View File

@ -1,26 +1,35 @@
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { SharedModule } from '../shared/shared.module'; import { SharedModule } from '../shared/shared.module';
import { DashboardComponent } from './dashboard.component';
import { CalendarComponent } from './calendar/calendar.component'; import { CalendarComponent } from './calendar/calendar.component';
import { ChartsComponent } from './charts/charts.component'; import { ChartsComponent } from './charts/charts.component';
import { DashboardComponent } from './dashboard.component';
import { DashboardService } from './dashboard.service';
import { MyItemsComponent } from './my-items/my-items.component';
@NgModule({ @NgModule({
imports: [ imports: [
CommonModule, CommonModule,
FormsModule,
SharedModule SharedModule
], ],
declarations: [ declarations: [
DashboardComponent,
CalendarComponent, CalendarComponent,
ChartsComponent ChartsComponent,
DashboardComponent,
MyItemsComponent
],
providers: [
DashboardService
], ],
exports: [ exports: [
DashboardComponent,
CalendarComponent, CalendarComponent,
ChartsComponent ChartsComponent,
DashboardComponent,
MyItemsComponent
] ]
}) })
export class DashboardModule { } export class DashboardModule { }

View File

@ -0,0 +1,33 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { LocationStrategy } from '@angular/common';
import { Observable, of } from 'rxjs';
import { map, catchError } from 'rxjs/operators';
import { ApiResponse } from '../shared/models';
import { ApiService } from '../shared/services';
@Injectable()
export class DashboardService extends ApiService {
constructor(private http: HttpClient, strat: LocationStrategy) {
super(strat);
}
getBoardInfo(): Observable<ApiResponse> {
return this.http.get(this.apiBase + 'dashboard/boards')
.pipe(
map((response: ApiResponse) => response),
catchError((err) => of(err.error as ApiResponse))
);
}
getTaskInfo(): Observable<ApiResponse> {
return this.http.get(this.apiBase + 'dashboard/tasks')
.pipe(
map((response: ApiResponse) => response),
catchError((err) => of(err.error as ApiResponse))
);
}
}

View File

@ -0,0 +1,112 @@
<section class="boards-and-tasks">
<h2>{{ strings['dashboard_boardsAndTasks'] }}</h2>
<div class="row">
<h3>{{ strings['dashboard_myBoards'] }}</h3>
<table class="alternating scrollable">
<thead>
<tr>
<th>{{ strings['settings_board'] }}</th>
<th>{{ strings['settings_columns'] }}</th>
<th>{{ strings['settings_categories'] }}</th>
</tr>
</thead>
<tbody *ngIf="boardsLoading">
<tr>
<td colspan="3">
Loading ...
</td>
</tr>
</tbody>
<tbody *ngIf="!boardsLoading && !boards">
<tr>
<td colspan="3">
{{ boardsMessage }}
</td>
</tr>
</tbody>
<tbody *ngIf="boards && !boardsLoading">
<tr *ngFor="let board of boards">
<td><a href="./boards/{{ board.id }}">{{ board.name }}</a></td>
<td>
<span *ngFor="let col of board.columns">
{{ col.name }}
<span class="badge" title="{{ strings['boards_tasksInColumn'] }}">
{{ col.tasks }}</span>
</span>
</td>
<td>
<!-- Ugly here, so it looks good in the browser -->
<span *ngFor="let cat of board.categories">{{ cat.name }}
<span class="badge"
title="{{ strings['dashboard_tasksInCategory'] }}">{{ cat.tasks }}</span></span>
</td>
</tr>
</tbody>
</table>
</div>
<div class="row">
<h3>{{ strings['dashboard_myTasks'] }}</h3>
<table class="alternating scrollable">
<thead>
<tr>
<th>{{ strings['settings_board'] }}</th>
<th>{{ strings['boards_task'] }}</th>
<th>{{ strings['dashboard_details'] }}</th>
</tr>
</thead>
<tbody *ngIf="tasksLoading">
<tr>
<td colspan="3">
Loading ...
</td>
</tr>
</tbody>
<tbody *ngIf="!tasksLoading && !tasks">
<tr>
<td colspan="3">
{{ tasksMessage }}
</td>
</tr>
</tbody>
<tbody *ngIf="tasks && !tasksLoading">
<tr *ngFor="let task of tasks">
<td><a href="./boards/{{ task.board_id }}">{{ task.board }}</a></td>
<td>
<!-- <a href="#">An Important Task</a> -->
{{ task.title }}
<span class="badge" title="Task Color"
style="background-color:{{ task.color }}">&nbsp;</span>
</td>
<td>
<span class="details">{{ strings['boards_taskColumn'] }}:
<em>{{ task.column }}</em></span>
<span class="details" *ngIf="task.date_due">{{ strings['boards_taskDateDue'] }}:
<em>{{ task.date_due | date }}</em></span>
<span class="details" *ngIf="task.points">{{ strings['boards_taskPoints'] }}:
<em>{{ task.points }}</em></span>
<span class="details" *ngIf="task.attachments.length">{{ strings['boards_taskAttachments'] }}:
<em>{{ task.attachments }}</em></span>
<span class="details" *ngIf="task.comments.length">{{ strings['boards_taskComments'] }}:
<em>{{ task.comments }}</em></span>
</td>
</tr>
</tbody>
</table>
</div>
</section>

View File

@ -0,0 +1,35 @@
import { Component, Input } from '@angular/core';
@Component({
selector: 'tb-my-items',
templateUrl: './my-items.component.html'
})
export class MyItemsComponent {
@Input()
boards: any;
@Input()
boardsLoading: boolean;
@Input()
boardsMessage: string;
@Input()
tasks: any;
@Input()
tasksLoading: boolean;
@Input()
tasksMessage: string;
@Input()
strings: any[];
constructor() {
this.boardsLoading = true;
this.tasksLoading = true;
}
}

View File

@ -94,7 +94,7 @@
"settings_issueTrackerRegExp": "BUGID RegExp", "settings_issueTrackerRegExp": "BUGID RegExp",
"settings_addIssueTracker": "Add Issue Tracker", "settings_addIssueTracker": "Add Issue Tracker",
"settings_selectUsers": "Select Users", "settings_selectUsers": "Select Users",
"settings_boardAdminMessage": "Including a Board Admin, makes them an admin of this board.", "settings_boardAdminMessage": "Including a Board Admin makes them an admin of this board.",
"settings_adminAccessMessage": "Administrators have access to all boards and are not listed here.", "settings_adminAccessMessage": "Administrators have access to all boards and are not listed here.",
"settings_saveBoard": "Save Board", "settings_saveBoard": "Save Board",
"settings_noBoards": "You are not assigned to any boards. Contact an admin user to be added to a board.", "settings_noBoards": "You are not assigned to any boards. Contact an admin user to be added to a board.",
@ -223,6 +223,13 @@
"boards_sortByPosition": "Position", "boards_sortByPosition": "Position",
"boards_sortByDueDate": "Due Date", "boards_sortByDueDate": "Due Date",
"boards_sortByLastModified": "Last Modifed", "boards_sortByLastModified": "Last Modifed",
"boards_sortByPoints": "Points" "boards_sortByPoints": "Points",
"dashboard_boardsAndTasks": "Boards and Tasks",
"dashboard_myBoards": "My Boards",
"dashboard_myTasks": "My Tasks",
"dashboard_tasksInCategory": "Tasks In Category",
"dashboard_details": "Details",
"dashboard_analytics": "Analytics"
} }

View File

@ -223,6 +223,12 @@
"boards_sortByPosition": "Posición", "boards_sortByPosition": "Posición",
"boards_sortByDueDate": "Fecha de Vencimiento", "boards_sortByDueDate": "Fecha de Vencimiento",
"boards_sortByLastModified": "Última Modificación", "boards_sortByLastModified": "Última Modificación",
"boards_sortByPoints": "Puntos" "boards_sortByPoints": "Puntos",
"dashboard_boardsAndTasks": "Tableros y Tareas",
"dashboard_myBoards": "Mis Tableros",
"dashboard_myTasks": "Mis Tareas",
"dashboard_tasksInCategory": "Tareas En Categoría",
"dashboard_details": "Detalles"
} }

View File

@ -223,5 +223,11 @@
"boards_sortByPosition": "position", "boards_sortByPosition": "position",
"boards_sortByDueDate": "date d'échéance", "boards_sortByDueDate": "date d'échéance",
"boards_sortByLastModified": "dernière modification", "boards_sortByLastModified": "dernière modification",
"boards_sortByPoints": "points" "boards_sortByPoints": "points",
"dashboard_boardsAndTasks": "Tableaux et Tâches",
"dashboard_myBoards": "Mes Tableaux",
"dashboard_myTasks": "Mes Tâches",
"dashboard_tasksInCategory": "Tâches dans la Catégorie",
"dashboard_details": "Détails"
} }

View File

@ -3,6 +3,22 @@
margin: 7px 1em; margin: 7px 1em;
.boards-and-tasks {
.scrollable {
tbody {
display: block;
max-height: 26vh;
overflow: auto;
}
thead, tbody tr, tfoot {
display: table;
table-layout: fixed;
width: 100%;
}
}
}
.details { .details {
margin-right: 1em; margin-right: 1em;
} }
@ -11,6 +27,11 @@
@include grid-column(9 of 18); @include grid-column(9 of 18);
} }
.error {
color: $color-secondary;
font-weight: bold;
}
.calendar { .calendar {
td { td {
width: 100px; width: 100px;

View File

@ -8,7 +8,7 @@
// chartist // chartist
@import 'chartist-settings'; @import 'chartist-settings';
@import '../../node_modules/chartist/dist/chartist.css'; @import '../../node_modules/chartist/dist/scss/chartist.scss';
// highlight.js // highlight.js
@import '../../node_modules/highlight.js/styles/tomorrow-night-eighties.css'; @import '../../node_modules/highlight.js/styles/tomorrow-night-eighties.css';