Skip to content

Commit 5ca4221

Browse files
committed
feat(openproject): add project_structure_dashboard frontend module
1 parent eea7e03 commit 5ca4221

12 files changed

Lines changed: 746 additions & 0 deletions
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<div class="psd-block">
2+
<div class="psd-block__header">
3+
<input
4+
type="text"
5+
class="psd-input"
6+
[readonly]="readonly"
7+
[ngModel]="node.title"
8+
(ngModelChange)="node.title = $event; nodeChange.emit(node)"
9+
placeholder="Block title"
10+
/>
11+
<input
12+
type="number"
13+
class="psd-input psd-input--coord"
14+
[readonly]="readonly"
15+
[ngModel]="node.x"
16+
(ngModelChange)="node.x = $event ? +$event : node.x; nodeChange.emit(node)"
17+
placeholder="x"
18+
/>
19+
<input
20+
type="number"
21+
class="psd-input psd-input--coord"
22+
[readonly]="readonly"
23+
[ngModel]="node.y"
24+
(ngModelChange)="node.y = $event ? +$event : node.y; nodeChange.emit(node)"
25+
placeholder="y"
26+
/>
27+
<input
28+
type="number"
29+
class="psd-input psd-input--query"
30+
[readonly]="readonly"
31+
[ngModel]="config[node.id]?.query_id"
32+
(ngModelChange)="onQueryIdChange($event)"
33+
placeholder="query_id"
34+
/>
35+
<button class="psd-btn" type="button" (click)="onAddChild()" [disabled]="readonly">+ child</button>
36+
<button class="psd-btn psd-btn--danger" type="button" (click)="onRemove()" [disabled]="readonly">×</button>
37+
</div>
38+
39+
<div class="psd-block__children" *ngIf="node.children?.length">
40+
<op-psd-block-tree
41+
*ngFor="let child of node.children"
42+
[node]="child"
43+
[config]="config"
44+
(nodeChange)="nodeChange.emit(node)"
45+
(configChange)="configChange.emit($event)"
46+
(addChild)="addChild.emit($event)"
47+
(remove)="remove.emit($event)"
48+
[readonly]="readonly"
49+
></op-psd-block-tree>
50+
</div>
51+
</div>
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
.psd-block
2+
border: 1px solid #d0d7de
3+
border-radius: 6px
4+
padding: 8px
5+
margin-bottom: 6px
6+
background: #f9fbfd
7+
8+
.psd-block__header
9+
display: flex
10+
gap: 8px
11+
align-items: center
12+
13+
.psd-input
14+
border: 1px solid #c3c9d4
15+
border-radius: 4px
16+
padding: 4px 6px
17+
flex: 1
18+
19+
.psd-input--query
20+
max-width: 120px
21+
22+
.psd-input--coord
23+
max-width: 70px
24+
25+
.psd-btn
26+
background: #1d6fde
27+
color: #fff
28+
border: none
29+
border-radius: 4px
30+
padding: 4px 8px
31+
cursor: pointer
32+
font-size: 12px
33+
34+
.psd-btn--danger
35+
background: #d14343
36+
37+
.psd-btn:disabled
38+
opacity: 0.5
39+
cursor: not-allowed
40+
41+
.psd-block__children
42+
margin-top: 8px
43+
padding-left: 12px
44+
border-left: 2px dashed #d0d7de
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
2+
import { CommonModule } from '@angular/common';
3+
import { FormBuilder, FormGroup, FormsModule, ReactiveFormsModule } from '@angular/forms';
4+
import { PSDBlockConfig, PSDBlockNode } from '../../project-structure-dashboard.models';
5+
6+
@Component({
7+
selector: 'op-psd-block-tree',
8+
templateUrl: './block-tree.component.html',
9+
styleUrls: ['./block-tree.component.sass'],
10+
changeDetection: ChangeDetectionStrategy.OnPush,
11+
standalone: true,
12+
imports: [CommonModule, FormsModule, ReactiveFormsModule],
13+
})
14+
export class BlockTreeComponent {
15+
@Input() node!:PSDBlockNode;
16+
@Input() config:Record<string, PSDBlockConfig> = {};
17+
@Input() readonly = false;
18+
19+
@Output() nodeChange = new EventEmitter<PSDBlockNode>();
20+
@Output() configChange = new EventEmitter<{ id:string; config:PSDBlockConfig }>();
21+
@Output() addChild = new EventEmitter<string>();
22+
@Output() remove = new EventEmitter<string>();
23+
24+
form:FormGroup;
25+
26+
constructor(private readonly fb:FormBuilder) {
27+
this.form = this.fb.group({
28+
title: [''],
29+
query_id: [''],
30+
x: [''],
31+
y: [''],
32+
width: [''],
33+
height: [''],
34+
});
35+
}
36+
37+
ngOnInit():void {
38+
this.form.patchValue({
39+
title: this.node.title,
40+
query_id: this.config[this.node.id]?.query_id || '',
41+
x: this.node.x,
42+
y: this.node.y,
43+
width: this.node.width,
44+
height: this.node.height,
45+
});
46+
47+
this.form.valueChanges.subscribe((values) => {
48+
if (this.readonly) return;
49+
50+
this.node.title = values.title;
51+
this.node.x = values.x ? Number(values.x) : this.node.x;
52+
this.node.y = values.y ? Number(values.y) : this.node.y;
53+
this.node.width = values.width ? Number(values.width) : this.node.width;
54+
this.node.height = values.height ? Number(values.height) : this.node.height;
55+
this.nodeChange.emit(this.node);
56+
57+
const cfg:PSDBlockConfig = {
58+
...(this.config[this.node.id] || {}),
59+
query_id: values.query_id ? Number(values.query_id) : undefined,
60+
};
61+
this.configChange.emit({ id: this.node.id, config: cfg });
62+
});
63+
}
64+
65+
onAddChild():void {
66+
this.addChild.emit(this.node.id);
67+
}
68+
69+
onRemove():void {
70+
this.remove.emit(this.node.id);
71+
}
72+
73+
onQueryIdChange(value:any):void {
74+
const cfg:PSDBlockConfig = {
75+
...(this.config[this.node.id] || {}),
76+
query_id: value ? Number(value) : undefined,
77+
};
78+
this.configChange.emit({ id: this.node.id, config: cfg });
79+
}
80+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<div
2+
class="psd-canvas"
3+
(mousemove)="onMouseMove($event)"
4+
(mouseup)="onMouseUp()"
5+
(mouseleave)="onMouseUp()"
6+
>
7+
<div
8+
class="psd-canvas__block"
9+
*ngFor="let block of blocks"
10+
[style.left.px]="block.x || 20 * block.depth"
11+
[style.top.px]="block.y || 20 * block.depth"
12+
[style.width.px]="block.width || 180"
13+
[style.height.px]="block.height || 120"
14+
(mousedown)="onMouseDown($event, block)"
15+
>
16+
<div class="psd-canvas__title">
17+
<span>{{ block.title }}</span>
18+
<span class="psd-canvas__depth">Lvl {{ block.depth }}</span>
19+
</div>
20+
<div class="psd-canvas__counts" *ngIf="counts[block.id]">
21+
<span>Done: {{ counts[block.id].completed }}</span>
22+
<span>In progress: {{ counts[block.id].in_progress }}</span>
23+
<span>Pending: {{ counts[block.id].pending }}</span>
24+
<span>Other: {{ counts[block.id].other }}</span>
25+
<span>Total: {{ counts[block.id].total }}</span>
26+
<a class="psd-link" [href]="counts[block.id].drill_down_url" target="_blank">Open</a>
27+
</div>
28+
<div class="psd-canvas__resize" (mousedown)="onResizeDown($event, block)"></div>
29+
</div>
30+
</div>
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
.psd-canvas
2+
position: relative
3+
min-height: 500px
4+
background: linear-gradient(90deg, #f5f7fb 1px, transparent 1px), linear-gradient(180deg, #f5f7fb 1px, transparent 1px)
5+
background-size: 40px 40px
6+
border: 1px dashed #c3c9d4
7+
border-radius: 8px
8+
overflow: hidden
9+
10+
.psd-canvas__block
11+
position: absolute
12+
background: #ffffff
13+
border: 1px solid #c3c9d4
14+
border-radius: 8px
15+
box-shadow: 0 4px 10px rgba(0,0,0,0.06)
16+
padding: 8px
17+
cursor: move
18+
display: flex
19+
flex-direction: column
20+
gap: 6px
21+
22+
.psd-canvas__title
23+
display: flex
24+
justify-content: space-between
25+
font-weight: 600
26+
27+
.psd-canvas__depth
28+
font-size: 12px
29+
color: #6b7280
30+
31+
.psd-canvas__counts
32+
display: grid
33+
grid-template-columns: repeat(2, minmax(0, 1fr))
34+
gap: 4px
35+
font-size: 12px
36+
37+
.psd-canvas__resize
38+
position: absolute
39+
width: 12px
40+
height: 12px
41+
right: 2px
42+
bottom: 2px
43+
background: #1d6fde
44+
border-radius: 3px
45+
cursor: se-resize
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
2+
import { CommonModule } from '@angular/common';
3+
import { PSDBlockCounts, PSDBlockNode } from '../../project-structure-dashboard.models';
4+
5+
interface BlockView extends PSDBlockNode {
6+
depth:number;
7+
}
8+
9+
@Component({
10+
selector: 'op-psd-diagram-canvas',
11+
templateUrl: './diagram-canvas.component.html',
12+
styleUrls: ['./diagram-canvas.component.sass'],
13+
changeDetection: ChangeDetectionStrategy.OnPush,
14+
standalone: true,
15+
imports: [CommonModule],
16+
})
17+
export class DiagramCanvasComponent {
18+
@Input() root!:PSDBlockNode;
19+
@Input() counts:Record<string, PSDBlockCounts> = {};
20+
@Input() readonly = false;
21+
@Output() positionChange = new EventEmitter<{ id:string; x:number; y:number; width:number; height:number }>();
22+
23+
draggingId:string | null = null;
24+
resizingId:string | null = null;
25+
dragOffset = { x: 0, y: 0 };
26+
27+
get blocks():BlockView[] {
28+
const list:BlockView[] = [];
29+
this.walk(this.root, 0, list);
30+
return list;
31+
}
32+
33+
walk(node:PSDBlockNode, depth:number, acc:BlockView[]):void {
34+
acc.push({ ...node, depth });
35+
(node.children || []).forEach((child) => this.walk(child, depth + 1, acc));
36+
}
37+
38+
onMouseDown(event:MouseEvent, block:BlockView):void {
39+
if (this.readonly) return;
40+
this.draggingId = block.id;
41+
this.dragOffset = { x: event.offsetX, y: event.offsetY };
42+
event.preventDefault();
43+
}
44+
45+
onResizeDown(event:MouseEvent, block:BlockView):void {
46+
if (this.readonly) return;
47+
this.resizingId = block.id;
48+
event.stopPropagation();
49+
event.preventDefault();
50+
}
51+
52+
onMouseMove(event:MouseEvent):void {
53+
if (!this.draggingId && !this.resizingId) return;
54+
event.preventDefault();
55+
const canvas = (event.currentTarget as HTMLElement).getBoundingClientRect();
56+
if (this.draggingId) {
57+
const x = event.clientX - canvas.left - this.dragOffset.x;
58+
const y = event.clientY - canvas.top - this.dragOffset.y;
59+
this.positionChange.emit({
60+
id: this.draggingId,
61+
x: Math.max(0, x),
62+
y: Math.max(0, y),
63+
width: undefined as unknown as number,
64+
height: undefined as unknown as number,
65+
});
66+
}
67+
if (this.resizingId) {
68+
const block = this.blocks.find((b) => b.id === this.resizingId);
69+
if (!block) return;
70+
const width = Math.max(120, event.clientX - canvas.left - (block.x ?? 0));
71+
const height = Math.max(80, event.clientY - canvas.top - (block.y ?? 0));
72+
this.positionChange.emit({
73+
id: this.resizingId,
74+
x: block.x ?? 0,
75+
y: block.y ?? 0,
76+
width,
77+
height,
78+
});
79+
}
80+
}
81+
82+
onMouseUp():void {
83+
this.draggingId = null;
84+
this.resizingId = null;
85+
}
86+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<div class="psd-page" *ngIf="selectedDashboard$ | async as dashboard">
2+
<header class="psd-page__header">
3+
<h2>Project Structure Dashboard</h2>
4+
<div class="psd-page__actions">
5+
<button class="psd-btn" type="button" (click)="addDashboard()">Add dashboard</button>
6+
<select class="psd-select" [ngModel]="dashboard.id" (ngModelChange)="selectDashboard($event)">
7+
<option *ngFor="let d of dashboards$ | async" [ngValue]="d.id">{{ d.name }}</option>
8+
</select>
9+
<button class="psd-btn" type="button" (click)="saveDashboard()">Save</button>
10+
<button class="psd-btn" type="button" (click)="aggregate()">Aggregate</button>
11+
</div>
12+
</header>
13+
14+
<section class="psd-grid">
15+
<div class="psd-editor">
16+
<h3>Editor</h3>
17+
<op-psd-block-tree
18+
[node]="dashboard.structure_data.root"
19+
[config]="dashboard.block_configurations"
20+
(nodeChange)="onNodeChange($event)"
21+
(configChange)="onConfigChange($event)"
22+
(addChild)="onAddChild($event)"
23+
(remove)="onRemove($event)"
24+
></op-psd-block-tree>
25+
</div>
26+
<div class="psd-viewer">
27+
<h3>Counts</h3>
28+
<div *ngFor="let entry of (counts$ | async) | keyvalue" class="psd-count-card">
29+
<div class="psd-count-card__title">{{ entry.key }}</div>
30+
<div class="psd-count-card__counts">
31+
<span>Done: {{ entry.value.completed }}</span>
32+
<span>In progress: {{ entry.value.in_progress }}</span>
33+
<span>Pending: {{ entry.value.pending }}</span>
34+
<span>Other: {{ entry.value.other }}</span>
35+
<span>Total: {{ entry.value.total }}</span>
36+
</div>
37+
<a class="psd-link" [href]="entry.value.drill_down_url" target="_blank">Open list</a>
38+
</div>
39+
<h3>Diagram</h3>
40+
<op-psd-diagram-canvas
41+
[root]="dashboard.structure_data.root"
42+
[counts]="(counts$ | async) ?? {}"
43+
(positionChange)="onPositionChange($event)"
44+
></op-psd-diagram-canvas>
45+
</div>
46+
</section>
47+
</div>
48+
49+
<div class="psd-empty" *ngIf="!(selectedDashboard$ | async)">
50+
<p>No dashboards yet.</p>
51+
<button class="psd-btn" type="button" (click)="addDashboard()">Create one</button>
52+
</div>

0 commit comments

Comments
 (0)