feat: Goa GEL Blockchain e-Licensing Platform - Full Stack Implementation

Complete implementation of the Goa Government e-Licensing platform with:

Backend:
- NestJS API with JWT authentication
- PostgreSQL database with Knex ORM
- Redis caching and session management
- MinIO document storage
- Hyperledger Besu blockchain integration
- Multi-department workflow system
- Comprehensive API tests (266/282 passing)

Frontend:
- Angular 21 with standalone components
- Angular Material + TailwindCSS UI
- Visual workflow builder
- Document upload with progress tracking
- Blockchain explorer integration
- Role-based dashboards (Admin, Department, Citizen)
- E2E tests with Playwright (37 tests)

Infrastructure:
- Docker Compose orchestration
- Blockscout blockchain explorer
- Development and production configurations
This commit is contained in:
Mahi
2026-02-07 10:23:29 -04:00
commit 80566bf0a2
441 changed files with 102418 additions and 0 deletions

View File

@@ -0,0 +1,45 @@
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { ApiService } from '../../../core/services/api.service';
import {
WorkflowResponseDto,
CreateWorkflowDto,
UpdateWorkflowDto,
PaginatedWorkflowsResponse,
WorkflowValidationResultDto,
} from '../../../api/models';
@Injectable({
providedIn: 'root',
})
export class WorkflowService {
private readonly api = inject(ApiService);
getWorkflows(page = 1, limit = 10): Observable<PaginatedWorkflowsResponse> {
return this.api.get<PaginatedWorkflowsResponse>('/workflows', { page, limit });
}
getWorkflow(id: string): Observable<WorkflowResponseDto> {
return this.api.get<WorkflowResponseDto>(`/workflows/${id}`);
}
createWorkflow(dto: CreateWorkflowDto): Observable<WorkflowResponseDto> {
return this.api.post<WorkflowResponseDto>('/workflows', dto);
}
updateWorkflow(id: string, dto: UpdateWorkflowDto): Observable<WorkflowResponseDto> {
return this.api.patch<WorkflowResponseDto>(`/workflows/${id}`, dto);
}
deleteWorkflow(id: string): Observable<void> {
return this.api.delete<void>(`/workflows/${id}`);
}
validateWorkflow(dto: CreateWorkflowDto): Observable<WorkflowValidationResultDto> {
return this.api.post<WorkflowValidationResultDto>('/workflows/validate', dto);
}
toggleActive(id: string, isActive: boolean): Observable<WorkflowResponseDto> {
return this.api.patch<WorkflowResponseDto>(`/workflows/${id}`, { isActive });
}
}

View File

@@ -0,0 +1,464 @@
<div class="workflow-builder">
<!-- Header -->
<header class="builder-header">
<div class="header-left">
<button mat-icon-button (click)="goBack()" matTooltip="Back to workflows">
<mat-icon>arrow_back</mat-icon>
</button>
<div class="header-title">
<h1>{{ isEditMode() ? 'Edit Workflow' : 'Create Workflow' }}</h1>
<p class="subtitle">Visual workflow designer</p>
</div>
</div>
<div class="header-center">
<!-- Workflow Name Input -->
<div class="workflow-name-input">
<mat-form-field appearance="outline" class="name-field">
<mat-icon matPrefix>edit</mat-icon>
<input matInput [formControl]="workflowForm.controls.name" placeholder="Workflow Name">
</mat-form-field>
</div>
</div>
<div class="header-right">
<div class="workflow-stats">
<span class="stat">
<mat-icon>account_tree</mat-icon>
{{ stageCount() }} stages
</span>
<span class="stat">
<mat-icon>link</mat-icon>
{{ connectionCount() }} connections
</span>
</div>
@if (hasUnsavedChanges()) {
<span class="unsaved-badge">
<mat-icon>edit_note</mat-icon>
Unsaved
</span>
}
<button mat-stroked-button (click)="goBack()">
Cancel
</button>
<button
mat-flat-button
color="primary"
(click)="saveWorkflow()"
[disabled]="saving() || workflowForm.invalid"
>
@if (saving()) {
<mat-spinner diameter="20"></mat-spinner>
} @else {
<ng-container>
<mat-icon>save</mat-icon>
Save Workflow
</ng-container>
}
</button>
</div>
</header>
<!-- Main Content -->
<div class="builder-content">
<!-- Left Toolbar -->
<aside class="toolbar-left">
<div class="toolbar-section">
<span class="toolbar-label">Tools</span>
<button
mat-icon-button
[class.active]="currentTool() === 'select'"
(click)="setTool('select')"
matTooltip="Select (V)"
>
<mat-icon>near_me</mat-icon>
</button>
<button
mat-icon-button
[class.active]="currentTool() === 'connect'"
(click)="setTool('connect')"
matTooltip="Connect (C)"
>
<mat-icon>link</mat-icon>
</button>
<button
mat-icon-button
[class.active]="currentTool() === 'pan'"
(click)="setTool('pan')"
matTooltip="Pan (H)"
>
<mat-icon>pan_tool</mat-icon>
</button>
</div>
<mat-divider></mat-divider>
<div class="toolbar-section">
<span class="toolbar-label">Add</span>
<button
mat-icon-button
(click)="addStage()"
matTooltip="Add Stage"
>
<mat-icon>add_circle</mat-icon>
</button>
</div>
<mat-divider></mat-divider>
<div class="toolbar-section">
<span class="toolbar-label">View</span>
<button mat-icon-button (click)="zoomIn()" matTooltip="Zoom In">
<mat-icon>zoom_in</mat-icon>
</button>
<button mat-icon-button (click)="zoomOut()" matTooltip="Zoom Out">
<mat-icon>zoom_out</mat-icon>
</button>
<button mat-icon-button (click)="resetZoom()" matTooltip="Reset View">
<mat-icon>fit_screen</mat-icon>
</button>
<button mat-icon-button (click)="autoLayout()" matTooltip="Auto Layout">
<mat-icon>auto_fix_high</mat-icon>
</button>
</div>
<div class="toolbar-spacer"></div>
<div class="zoom-indicator">
{{ (canvasZoom() * 100) | number:'1.0-0' }}%
</div>
</aside>
<!-- Canvas Area -->
<main class="canvas-container">
@if (loading()) {
<div class="loading-overlay">
<mat-spinner diameter="48"></mat-spinner>
<p>Loading workflow...</p>
</div>
} @else {
<div
#canvas
class="canvas"
[style.transform]="'scale(' + canvasZoom() + ')'"
[style.transform-origin]="'top left'"
(click)="selectStage(null)"
>
<!-- SVG Connections Layer -->
<svg #svgConnections class="connections-layer">
<defs>
<!-- Arrow marker -->
<marker
id="arrowhead"
markerWidth="10"
markerHeight="7"
refX="9"
refY="3.5"
orient="auto"
>
<polygon
points="0 0, 10 3.5, 0 7"
fill="var(--dbim-blue-mid, #2563EB)"
/>
</marker>
<!-- Highlighted arrow marker -->
<marker
id="arrowhead-highlight"
markerWidth="10"
markerHeight="7"
refX="9"
refY="3.5"
orient="auto"
>
<polygon
points="0 0, 10 3.5, 0 7"
fill="var(--dbim-success, #198754)"
/>
</marker>
</defs>
<!-- Connection paths -->
@for (conn of connections(); track conn.from + '-' + conn.to) {
<g class="connection-group" (click)="deleteConnection(conn.from, conn.to); $event.stopPropagation()">
<path
[attr.d]="getConnectionPath(conn)"
class="connection-path"
[class.highlighted]="conn.isHighlighted"
marker-end="url(#arrowhead)"
/>
<path
[attr.d]="getConnectionPath(conn)"
class="connection-hitbox"
/>
</g>
}
<!-- Connecting indicator shown on canvas when in connecting mode -->
</svg>
<!-- Stage Nodes -->
@for (stage of stages(); track stage.id) {
<div
class="stage-node"
[class.selected]="stage.isSelected"
[class.start-node]="stage.isStartNode"
[class.end-node]="stage.isEndNode"
[class.connecting-from]="connectingFromId() === stage.id"
[style.left.px]="stage.position.x"
[style.top.px]="stage.position.y"
cdkDrag
[cdkDragDisabled]="currentTool() !== 'select'"
(cdkDragMoved)="onStageDragMoved($event, stage.id)"
(cdkDragEnded)="onStageDragEnded($event, stage.id)"
(click)="selectStage(stage.id); $event.stopPropagation()"
>
<!-- Node Header -->
<div class="node-header" [class.has-department]="stage.departmentId">
<div class="node-icon">
@if (stage.isStartNode) {
<mat-icon>play_circle</mat-icon>
} @else if (stage.isEndNode) {
<mat-icon>check_circle</mat-icon>
} @else {
<mat-icon>{{ getDepartmentIcon(stage.departmentId) }}</mat-icon>
}
</div>
<div class="node-title">
<span class="stage-name">{{ stage.name }}</span>
@if (stage.departmentId) {
<span class="department-name">{{ getDepartmentName(stage.departmentId) }}</span>
} @else if (!stage.isStartNode) {
<span class="department-name unassigned">Click to configure</span>
}
</div>
@if (!stage.isStartNode) {
<button
mat-icon-button
class="node-menu-btn"
[matMenuTriggerFor]="nodeMenu"
(click)="$event.stopPropagation()"
>
<mat-icon>more_vert</mat-icon>
</button>
<mat-menu #nodeMenu="matMenu">
<button mat-menu-item (click)="selectStage(stage.id)">
<mat-icon>edit</mat-icon>
<span>Configure</span>
</button>
<button mat-menu-item (click)="startConnecting(stage.id)">
<mat-icon>link</mat-icon>
<span>Connect to...</span>
</button>
<mat-divider></mat-divider>
<button mat-menu-item (click)="deleteStage(stage.id)" class="delete-item">
<mat-icon>delete</mat-icon>
<span>Delete</span>
</button>
</mat-menu>
}
</div>
<!-- Node Body -->
<div class="node-body">
@if (stage.description) {
<p class="node-description">{{ stage.description }}</p>
}
<div class="node-badges">
@if (stage.isRequired) {
<span class="badge required">Required</span>
}
@if (stage.metadata?.['executionType'] === 'PARALLEL') {
<span class="badge parallel">Parallel</span>
}
</div>
</div>
<!-- Connection Points -->
@if (!stage.isStartNode) {
<div
class="connection-point input"
[class.can-connect]="isConnecting() && connectingFromId() !== stage.id"
(click)="completeConnection(stage.id); $event.stopPropagation()"
></div>
}
@if (!stage.isEndNode || currentTool() === 'connect') {
<div
class="connection-point output"
[class.connecting]="connectingFromId() === stage.id"
(click)="startConnecting(stage.id); $event.stopPropagation()"
></div>
}
</div>
}
<!-- Empty State -->
@if (stages().length === 0) {
<div class="canvas-empty-state">
<mat-icon>account_tree</mat-icon>
<h3>Start Building Your Workflow</h3>
<p>Click the + button to add your first stage</p>
<button mat-flat-button color="primary" (click)="addStage()">
<mat-icon>add</mat-icon>
Add First Stage
</button>
</div>
}
</div>
}
<!-- Connecting Mode Indicator -->
@if (isConnecting()) {
<div class="connecting-indicator">
<mat-icon>link</mat-icon>
Click on a stage to connect • Press ESC to cancel
</div>
}
</main>
<!-- Right Sidebar - Configuration Panel -->
<aside class="config-panel" [class.open]="selectedStage()">
@if (selectedStage(); as stage) {
<div class="panel-header">
<h3>Configure Stage</h3>
<button mat-icon-button (click)="selectStage(null)">
<mat-icon>close</mat-icon>
</button>
</div>
<div class="panel-content">
<form [formGroup]="stageForm" (ngSubmit)="updateSelectedStage()">
<!-- Stage Name -->
<mat-form-field appearance="outline" class="full-width">
<mat-label>Stage Name</mat-label>
<input matInput formControlName="name" placeholder="e.g., Fire Department Review">
<mat-icon matPrefix>label</mat-icon>
</mat-form-field>
<!-- Description -->
<mat-form-field appearance="outline" class="full-width">
<mat-label>Description</mat-label>
<textarea matInput formControlName="description" rows="2" placeholder="Brief description of this stage"></textarea>
<mat-icon matPrefix>description</mat-icon>
</mat-form-field>
<!-- Department -->
@if (!stage.isStartNode) {
<mat-form-field appearance="outline" class="full-width">
<mat-label>Assigned Department</mat-label>
<mat-select formControlName="departmentId">
<mat-option value="">-- Select Department --</mat-option>
@for (dept of departments(); track dept.id) {
<mat-option [value]="dept.id">
<div class="dept-option">
<mat-icon>{{ getDepartmentIcon(dept.id) }}</mat-icon>
{{ dept.name }}
</div>
</mat-option>
}
</mat-select>
<mat-icon matPrefix>business</mat-icon>
</mat-form-field>
<!-- Execution Type -->
<mat-form-field appearance="outline" class="full-width">
<mat-label>Execution Type</mat-label>
<mat-select formControlName="executionType">
<mat-option value="SEQUENTIAL">Sequential</mat-option>
<mat-option value="PARALLEL">Parallel</mat-option>
</mat-select>
<mat-icon matPrefix>call_split</mat-icon>
<mat-hint>Sequential waits for previous stage</mat-hint>
</mat-form-field>
<!-- Completion Criteria -->
<mat-form-field appearance="outline" class="full-width">
<mat-label>Completion Criteria</mat-label>
<mat-select formControlName="completionCriteria">
<mat-option value="ALL">All Approvers</mat-option>
<mat-option value="ANY">Any Approver</mat-option>
<mat-option value="THRESHOLD">Threshold</mat-option>
</mat-select>
<mat-icon matPrefix>rule</mat-icon>
</mat-form-field>
<!-- Timeout -->
<mat-form-field appearance="outline" class="full-width">
<mat-label>Timeout (hours)</mat-label>
<input matInput type="number" formControlName="timeoutHours" min="1">
<mat-icon matPrefix>schedule</mat-icon>
<mat-hint>Auto-escalate after timeout</mat-hint>
</mat-form-field>
<!-- Required Toggle -->
<div class="checkbox-field">
<mat-checkbox formControlName="isRequired">
Required Stage
</mat-checkbox>
<p class="field-hint">If unchecked, this stage can be skipped</p>
</div>
}
<div class="panel-actions">
<button
mat-flat-button
color="primary"
type="button"
(click)="updateSelectedStage()"
>
<mat-icon>check</mat-icon>
Apply Changes
</button>
</div>
</form>
@if (!stage.isStartNode) {
<mat-divider></mat-divider>
<div class="danger-zone">
<h4>Danger Zone</h4>
<button mat-stroked-button color="warn" (click)="deleteStage(stage.id)">
<mat-icon>delete</mat-icon>
Delete Stage
</button>
</div>
}
</div>
} @else {
<!-- No Selection State -->
<div class="panel-empty">
<mat-icon>touch_app</mat-icon>
<h4>No Stage Selected</h4>
<p>Click on a stage to configure it, or add a new stage to get started.</p>
</div>
}
</aside>
</div>
<!-- Bottom Info Bar -->
<footer class="builder-footer">
<div class="footer-left">
<mat-form-field appearance="outline" class="request-type-field">
<mat-label>Request Type</mat-label>
<mat-select [formControl]="workflowForm.controls.requestType">
<mat-option value="NEW_LICENSE">New License</mat-option>
<mat-option value="RENEWAL">Renewal</mat-option>
<mat-option value="AMENDMENT">Amendment</mat-option>
</mat-select>
</mat-form-field>
</div>
<div class="footer-center">
<span class="keyboard-hint">
<kbd>Del</kbd> Delete • <kbd>Esc</kbd> Deselect • <kbd>Ctrl+S</kbd> Save
</span>
</div>
<div class="footer-right">
<mat-checkbox [formControl]="workflowForm.controls.isActive">
Active Workflow
</mat-checkbox>
</div>
</footer>
</div>

View File

@@ -0,0 +1,648 @@
import { Component, OnInit, inject, signal, computed, ElementRef, ViewChild, HostListener } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule, ActivatedRoute, Router } from '@angular/router';
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { CdkDrag, CdkDragEnd, CdkDragMove, DragDropModule } from '@angular/cdk/drag-drop';
import { MatCardModule } from '@angular/material/card';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatMenuModule } from '@angular/material/menu';
import { MatDialogModule, MatDialog } from '@angular/material/dialog';
import { MatSnackBarModule } from '@angular/material/snack-bar';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatChipsModule } from '@angular/material/chips';
import { MatDividerModule } from '@angular/material/divider';
import { WorkflowService } from '../services/workflow.service';
import { DepartmentService } from '../../departments/services/department.service';
import { NotificationService } from '../../../core/services/notification.service';
import { WorkflowResponseDto, WorkflowStage, DepartmentResponseDto } from '../../../api/models';
// Node position interface for canvas positioning
interface NodePosition {
x: number;
y: number;
}
// Extended stage with visual properties
interface VisualStage extends WorkflowStage {
position: NodePosition;
isSelected: boolean;
isStartNode?: boolean;
isEndNode?: boolean;
connections: string[]; // IDs of connected stages (outgoing)
}
// Connection between stages
interface StageConnection {
from: string;
to: string;
isHighlighted?: boolean;
}
@Component({
selector: 'app-workflow-builder',
standalone: true,
imports: [
CommonModule,
RouterModule,
ReactiveFormsModule,
DragDropModule,
MatCardModule,
MatButtonModule,
MatIconModule,
MatTooltipModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
MatCheckboxModule,
MatMenuModule,
MatDialogModule,
MatSnackBarModule,
MatProgressSpinnerModule,
MatChipsModule,
MatDividerModule,
],
templateUrl: './workflow-builder.component.html',
styleUrls: ['./workflow-builder.component.scss'],
})
export class WorkflowBuilderComponent implements OnInit {
@ViewChild('canvas', { static: true }) canvasRef!: ElementRef<HTMLDivElement>;
@ViewChild('svgConnections', { static: true }) svgRef!: ElementRef<SVGElement>;
private readonly fb = inject(FormBuilder);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly dialog = inject(MatDialog);
private readonly workflowService = inject(WorkflowService);
private readonly departmentService = inject(DepartmentService);
private readonly notification = inject(NotificationService);
// State signals
readonly loading = signal(false);
readonly saving = signal(false);
readonly isEditMode = signal(false);
readonly workflowId = signal<string | null>(null);
readonly departments = signal<DepartmentResponseDto[]>([]);
// Canvas state
readonly stages = signal<VisualStage[]>([]);
readonly connections = signal<StageConnection[]>([]);
readonly selectedStageId = signal<string | null>(null);
readonly isConnecting = signal(false);
readonly connectingFromId = signal<string | null>(null);
readonly canvasZoom = signal(1);
readonly canvasPan = signal<NodePosition>({ x: 0, y: 0 });
// Tool modes
readonly currentTool = signal<'select' | 'connect' | 'pan'>('select');
// Workflow metadata form
readonly workflowForm = this.fb.nonNullable.group({
name: ['', Validators.required],
description: [''],
requestType: ['NEW_LICENSE', Validators.required],
isActive: [true],
});
// Stage configuration form
readonly stageForm = this.fb.nonNullable.group({
name: ['', Validators.required],
description: [''],
departmentId: ['', Validators.required],
isRequired: [true],
executionType: ['SEQUENTIAL'],
completionCriteria: ['ALL'],
timeoutHours: [72],
});
// Computed values
readonly selectedStage = computed(() => {
const id = this.selectedStageId();
return id ? this.stages().find(s => s.id === id) : null;
});
readonly hasUnsavedChanges = signal(false);
readonly stageCount = computed(() => this.stages().length);
readonly connectionCount = computed(() => this.connections().length);
// Stage ID counter for new stages
private stageIdCounter = 0;
ngOnInit(): void {
this.loadDepartments();
const id = this.route.snapshot.paramMap.get('id');
if (id && id !== 'new') {
this.workflowId.set(id);
this.isEditMode.set(true);
this.loadWorkflow(id);
} else {
// Create default start node
this.addStage('Start', true);
}
}
private loadDepartments(): void {
this.departmentService.getDepartments(1, 100).subscribe({
next: (response) => {
this.departments.set(response.data);
},
});
}
private loadWorkflow(id: string): void {
this.loading.set(true);
this.workflowService.getWorkflow(id).subscribe({
next: (workflow) => {
this.workflowForm.patchValue({
name: workflow.name,
description: workflow.description || '',
requestType: workflow.requestType,
isActive: workflow.isActive,
});
// Convert stages to visual stages with positions
const visualStages = workflow.stages.map((stage, index) => ({
...stage,
position: this.calculateStagePosition(index, workflow.stages.length),
isSelected: false,
isStartNode: index === 0,
isEndNode: index === workflow.stages.length - 1,
connections: index < workflow.stages.length - 1 ? [workflow.stages[index + 1].id] : [],
}));
this.stages.set(visualStages);
this.rebuildConnections();
this.loading.set(false);
},
error: () => {
this.notification.error('Failed to load workflow');
this.loading.set(false);
},
});
}
private getResponsiveSpacing(): { startX: number; startY: number; spacingX: number; zigzag: number } {
const screenWidth = window.innerWidth;
if (screenWidth <= 480) {
return { startX: 20, startY: 100, spacingX: 160, zigzag: 20 };
}
if (screenWidth <= 768) {
return { startX: 40, startY: 120, spacingX: 180, zigzag: 25 };
}
if (screenWidth <= 1024) {
return { startX: 60, startY: 150, spacingX: 220, zigzag: 30 };
}
return { startX: 100, startY: 200, spacingX: 280, zigzag: 40 };
}
private calculateStagePosition(index: number, total: number): NodePosition {
const { startX, startY, spacingX, zigzag } = this.getResponsiveSpacing();
// Layout in a horizontal line with some offset for visual clarity
return {
x: startX + index * spacingX,
y: startY + (index % 2) * zigzag, // Slight zigzag for visual interest
};
}
private rebuildConnections(): void {
const conns: StageConnection[] = [];
this.stages().forEach(stage => {
stage.connections.forEach(toId => {
conns.push({ from: stage.id, to: toId });
});
});
this.connections.set(conns);
}
// ========== Stage Management ==========
addStage(name?: string, isStart?: boolean): void {
const id = `stage-${++this.stageIdCounter}-${Date.now()}`;
const existingStages = this.stages();
const lastStage = existingStages[existingStages.length - 1];
const { startX, startY, spacingX } = this.getResponsiveSpacing();
const newStage: VisualStage = {
id,
name: name || `Stage ${existingStages.length + 1}`,
description: '',
departmentId: '',
order: existingStages.length + 1,
isRequired: true,
position: lastStage
? { x: lastStage.position.x + spacingX, y: lastStage.position.y }
: { x: startX, y: startY },
isSelected: false,
isStartNode: isStart || existingStages.length === 0,
connections: [],
};
// Auto-connect from last stage
if (lastStage && !isStart) {
const updatedStages = existingStages.map(s =>
s.id === lastStage.id
? { ...s, isEndNode: false, connections: [...s.connections, id] }
: s
);
newStage.isEndNode = true;
this.stages.set([...updatedStages, newStage]);
} else {
this.stages.set([...existingStages, newStage]);
}
this.rebuildConnections();
this.hasUnsavedChanges.set(true);
this.selectStage(id);
}
deleteStage(id: string): void {
const stageToDelete = this.stages().find(s => s.id === id);
if (!stageToDelete || stageToDelete.isStartNode) {
this.notification.error('Cannot delete the start stage');
return;
}
// Remove connections to this stage
const updatedStages = this.stages()
.filter(s => s.id !== id)
.map(s => ({
...s,
connections: s.connections.filter(c => c !== id),
}));
// Update order
updatedStages.forEach((s, i) => {
s.order = i + 1;
});
// Mark last as end node
if (updatedStages.length > 0) {
updatedStages[updatedStages.length - 1].isEndNode = true;
}
this.stages.set(updatedStages);
this.rebuildConnections();
if (this.selectedStageId() === id) {
this.selectedStageId.set(null);
}
this.hasUnsavedChanges.set(true);
}
selectStage(id: string | null): void {
this.selectedStageId.set(id);
// Update selection state in stages
this.stages.update(stages =>
stages.map(s => ({ ...s, isSelected: s.id === id }))
);
// Load stage data into form
if (id) {
const stage = this.stages().find(s => s.id === id);
if (stage) {
this.stageForm.patchValue({
name: stage.name,
description: stage.description || '',
departmentId: stage.departmentId,
isRequired: stage.isRequired,
executionType: (stage.metadata as any)?.executionType || 'SEQUENTIAL',
completionCriteria: (stage.metadata as any)?.completionCriteria || 'ALL',
timeoutHours: (stage.metadata as any)?.timeoutHours || 72,
});
}
}
}
// ========== Drag & Drop ==========
onStageDragMoved(event: CdkDragMove, stageId: string): void {
// Update connections in real-time during drag
this.updateSvgConnections();
}
onStageDragEnded(event: CdkDragEnd, stageId: string): void {
const element = event.source.element.nativeElement;
const rect = element.getBoundingClientRect();
const canvasRect = this.canvasRef.nativeElement.getBoundingClientRect();
const newPosition: NodePosition = {
x: rect.left - canvasRect.left + this.canvasRef.nativeElement.scrollLeft,
y: rect.top - canvasRect.top + this.canvasRef.nativeElement.scrollTop,
};
this.stages.update(stages =>
stages.map(s => s.id === stageId ? { ...s, position: newPosition } : s)
);
this.hasUnsavedChanges.set(true);
this.updateSvgConnections();
}
// ========== Connection Management ==========
startConnecting(fromId: string): void {
if (this.currentTool() !== 'connect') {
this.currentTool.set('connect');
}
this.isConnecting.set(true);
this.connectingFromId.set(fromId);
}
completeConnection(toId: string): void {
const fromId = this.connectingFromId();
if (!fromId || fromId === toId) {
this.cancelConnecting();
return;
}
// Check if connection already exists
const fromStage = this.stages().find(s => s.id === fromId);
if (fromStage?.connections.includes(toId)) {
this.notification.error('Connection already exists');
this.cancelConnecting();
return;
}
// Add connection
this.stages.update(stages =>
stages.map(s =>
s.id === fromId
? { ...s, connections: [...s.connections, toId] }
: s
)
);
this.rebuildConnections();
this.hasUnsavedChanges.set(true);
this.cancelConnecting();
}
cancelConnecting(): void {
this.isConnecting.set(false);
this.connectingFromId.set(null);
if (this.currentTool() === 'connect') {
this.currentTool.set('select');
}
}
deleteConnection(from: string, to: string): void {
this.stages.update(stages =>
stages.map(s =>
s.id === from
? { ...s, connections: s.connections.filter(c => c !== to) }
: s
)
);
this.rebuildConnections();
this.hasUnsavedChanges.set(true);
}
// ========== SVG Connection Rendering ==========
private getNodeWidth(): number {
// Responsive node width based on screen size
const screenWidth = window.innerWidth;
if (screenWidth <= 480) return 140;
if (screenWidth <= 768) return 160;
if (screenWidth <= 1024) return 180;
if (screenWidth <= 1200) return 200;
return 240;
}
private getNodeHeight(): number {
const screenWidth = window.innerWidth;
if (screenWidth <= 768) return 80;
return 100;
}
getConnectionPath(conn: StageConnection): string {
const fromStage = this.stages().find(s => s.id === conn.from);
const toStage = this.stages().find(s => s.id === conn.to);
if (!fromStage || !toStage) return '';
const nodeWidth = this.getNodeWidth();
const nodeHeight = this.getNodeHeight();
const fromX = fromStage.position.x + nodeWidth / 2; // Center of node
const fromY = fromStage.position.y + nodeHeight; // Bottom center
const toX = toStage.position.x + nodeWidth / 2;
const toY = toStage.position.y - 10; // Top center
// Bezier curve for smooth connection
const controlOffset = Math.abs(toY - fromY) / 2;
return `M ${fromX} ${fromY}
C ${fromX} ${fromY + controlOffset},
${toX} ${toY - controlOffset},
${toX} ${toY}`;
}
updateSvgConnections(): void {
// Force Angular to re-render SVG connections
this.connections.update(c => [...c]);
}
// ========== Stage Form ==========
updateSelectedStage(): void {
const id = this.selectedStageId();
if (!id) return;
const formValue = this.stageForm.getRawValue();
this.stages.update(stages =>
stages.map(s =>
s.id === id
? {
...s,
name: formValue.name,
description: formValue.description,
departmentId: formValue.departmentId,
isRequired: formValue.isRequired,
metadata: {
executionType: formValue.executionType,
completionCriteria: formValue.completionCriteria,
timeoutHours: formValue.timeoutHours,
},
}
: s
)
);
this.hasUnsavedChanges.set(true);
}
// ========== Workflow Save ==========
saveWorkflow(): void {
if (this.workflowForm.invalid) {
this.notification.error('Please fill in workflow details');
return;
}
if (this.stages().length === 0) {
this.notification.error('Workflow must have at least one stage');
return;
}
// Validate all stages have departments
const invalidStages = this.stages().filter(s => !s.departmentId && !s.isStartNode);
if (invalidStages.length > 0) {
this.notification.error(`Please assign departments to all stages: ${invalidStages.map(s => s.name).join(', ')}`);
return;
}
this.saving.set(true);
const workflowData = this.workflowForm.getRawValue();
const dto = {
name: workflowData.name,
description: workflowData.description || undefined,
requestType: workflowData.requestType,
stages: this.stages().map((s, index) => ({
id: s.id,
name: s.name,
description: s.description,
departmentId: s.departmentId,
order: index + 1,
isRequired: s.isRequired,
metadata: {
...s.metadata,
position: s.position,
connections: s.connections,
},
})),
metadata: {
visualLayout: {
stages: this.stages().map(s => ({
id: s.id,
position: s.position,
})),
connections: this.connections(),
},
},
};
const action$ = this.isEditMode()
? this.workflowService.updateWorkflow(this.workflowId()!, dto)
: this.workflowService.createWorkflow(dto);
action$.subscribe({
next: (result) => {
this.saving.set(false);
this.hasUnsavedChanges.set(false);
this.notification.success(this.isEditMode() ? 'Workflow updated' : 'Workflow created');
this.router.navigate(['/workflows', result.id]);
},
error: () => {
this.saving.set(false);
this.notification.error('Failed to save workflow');
},
});
}
// ========== Toolbar Actions ==========
setTool(tool: 'select' | 'connect' | 'pan'): void {
this.currentTool.set(tool);
if (tool !== 'connect') {
this.cancelConnecting();
}
}
zoomIn(): void {
this.canvasZoom.update(z => Math.min(z + 0.1, 2));
}
zoomOut(): void {
this.canvasZoom.update(z => Math.max(z - 0.1, 0.5));
}
resetZoom(): void {
this.canvasZoom.set(1);
this.canvasPan.set({ x: 0, y: 0 });
}
autoLayout(): void {
const stages = this.stages();
const updatedStages = stages.map((stage, index) => ({
...stage,
position: this.calculateStagePosition(index, stages.length),
}));
this.stages.set(updatedStages);
this.hasUnsavedChanges.set(true);
}
// ========== Department Helper ==========
getDepartmentName(id: string): string {
return this.departments().find(d => d.id === id)?.name || 'Unassigned';
}
getDepartmentIcon(id: string): string {
const dept = this.departments().find(d => d.id === id);
if (!dept) return 'business';
const code = dept.code?.toLowerCase() || '';
if (code.includes('fire')) return 'local_fire_department';
if (code.includes('tourism')) return 'flight';
if (code.includes('municipal')) return 'location_city';
if (code.includes('health')) return 'health_and_safety';
return 'business';
}
// ========== Window Resize Handler ==========
@HostListener('window:resize')
onWindowResize(): void {
// Update SVG connections when window resizes (node sizes change)
this.updateSvgConnections();
}
// ========== Keyboard Shortcuts ==========
@HostListener('document:keydown', ['$event'])
handleKeyboardEvent(event: KeyboardEvent): void {
// Delete selected stage
if (event.key === 'Delete' || event.key === 'Backspace') {
const selected = this.selectedStageId();
if (selected && !event.target?.toString().includes('Input')) {
this.deleteStage(selected);
}
}
// Escape to cancel connecting
if (event.key === 'Escape') {
this.cancelConnecting();
this.selectStage(null);
}
// Ctrl+S to save
if (event.ctrlKey && event.key === 's') {
event.preventDefault();
this.saveWorkflow();
}
}
// ========== Navigation ==========
goBack(): void {
if (this.hasUnsavedChanges()) {
if (confirm('You have unsaved changes. Are you sure you want to leave?')) {
this.router.navigate(['/workflows']);
}
} else {
this.router.navigate(['/workflows']);
}
}
}

View File

@@ -0,0 +1,365 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { FormBuilder, FormArray, ReactiveFormsModule, Validators } from '@angular/forms';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { PageHeaderComponent } from '../../../shared/components/page-header/page-header.component';
import { WorkflowService } from '../services/workflow.service';
import { DepartmentService } from '../../departments/services/department.service';
import { NotificationService } from '../../../core/services/notification.service';
import { DepartmentResponseDto } from '../../../api/models';
@Component({
selector: 'app-workflow-form',
standalone: true,
imports: [
CommonModule,
RouterModule,
ReactiveFormsModule,
MatCardModule,
MatFormFieldModule,
MatInputModule,
MatSelectModule,
MatButtonModule,
MatIconModule,
MatCheckboxModule,
MatProgressSpinnerModule,
PageHeaderComponent,
],
template: `
<div class="page-container">
<app-page-header
[title]="isEditMode() ? 'Edit Workflow' : 'Create Workflow'"
[subtitle]="isEditMode() ? 'Update workflow configuration' : 'Define a new approval workflow'"
>
<button mat-button routerLink="/workflows">
<mat-icon>arrow_back</mat-icon>
Back
</button>
</app-page-header>
<mat-card class="form-card">
<mat-card-content>
@if (loading()) {
<div class="loading-container">
<mat-spinner diameter="48"></mat-spinner>
</div>
} @else {
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<div class="form-section">
<h3>Basic Information</h3>
<div class="form-grid">
<mat-form-field appearance="outline">
<mat-label>Workflow Name</mat-label>
<input matInput formControlName="name" placeholder="e.g., Resort License Workflow" />
@if (form.controls.name.hasError('required')) {
<mat-error>Name is required</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Request Type</mat-label>
<mat-select formControlName="requestType">
<mat-option value="NEW_LICENSE">New License</mat-option>
<mat-option value="RENEWAL">Renewal</mat-option>
<mat-option value="AMENDMENT">Amendment</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="full-width">
<mat-label>Description</mat-label>
<textarea matInput formControlName="description" rows="2"></textarea>
</mat-form-field>
</div>
</div>
<div class="form-section">
<div class="section-header">
<h3>Approval Stages</h3>
<button mat-button type="button" color="primary" (click)="addStage()">
<mat-icon>add</mat-icon>
Add Stage
</button>
</div>
<div formArrayName="stages" class="stages-list">
@for (stage of stagesArray.controls; track $index; let i = $index) {
<mat-card class="stage-card" [formGroupName]="i">
<div class="stage-header">
<span class="stage-number">Stage {{ i + 1 }}</span>
<button mat-icon-button type="button" (click)="removeStage(i)" [disabled]="stagesArray.length <= 1">
<mat-icon>delete</mat-icon>
</button>
</div>
<div class="stage-form">
<mat-form-field appearance="outline">
<mat-label>Stage Name</mat-label>
<input matInput formControlName="name" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Department</mat-label>
<mat-select formControlName="departmentId">
@for (dept of departments(); track dept.id) {
<mat-option [value]="dept.id">{{ dept.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-checkbox formControlName="isRequired">Required</mat-checkbox>
</div>
</mat-card>
}
</div>
</div>
<div class="form-actions">
<button mat-button type="button" routerLink="/workflows">Cancel</button>
<button
mat-raised-button
color="primary"
type="submit"
[disabled]="form.invalid || submitting()"
>
@if (submitting()) {
<mat-spinner diameter="20"></mat-spinner>
} @else {
{{ isEditMode() ? 'Update' : 'Create' }}
}
</button>
</div>
</form>
}
</mat-card-content>
</mat-card>
</div>
`,
styles: [
`
.form-card {
max-width: 900px;
}
.loading-container {
display: flex;
justify-content: center;
padding: 48px;
}
.form-section {
margin-bottom: 32px;
h3 {
margin: 0 0 16px;
font-size: 1.125rem;
font-weight: 500;
}
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
h3 {
margin: 0;
}
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.full-width {
grid-column: 1 / -1;
}
.stages-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.stage-card {
padding: 16px;
}
.stage-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.stage-number {
font-weight: 500;
color: #1976d2;
}
.stage-form {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 16px;
align-items: center;
}
.form-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
padding-top: 16px;
border-top: 1px solid #eee;
}
`,
],
})
export class WorkflowFormComponent implements OnInit {
private readonly fb = inject(FormBuilder);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly workflowService = inject(WorkflowService);
private readonly departmentService = inject(DepartmentService);
private readonly notification = inject(NotificationService);
readonly loading = signal(false);
readonly submitting = signal(false);
readonly isEditMode = signal(false);
readonly departments = signal<DepartmentResponseDto[]>([]);
private workflowId: string | null = null;
readonly form = this.fb.nonNullable.group({
name: ['', [Validators.required]],
description: [''],
requestType: ['NEW_LICENSE', [Validators.required]],
stages: this.fb.array([this.createStageGroup()]),
});
get stagesArray(): FormArray {
return this.form.get('stages') as FormArray;
}
ngOnInit(): void {
this.loadDepartments();
this.workflowId = this.route.snapshot.paramMap.get('id');
if (this.workflowId) {
this.isEditMode.set(true);
this.loadWorkflow();
}
}
private loadDepartments(): void {
this.departmentService.getDepartments(1, 100).subscribe({
next: (response) => {
this.departments.set(response.data);
},
});
}
private loadWorkflow(): void {
if (!this.workflowId) return;
this.loading.set(true);
this.workflowService.getWorkflow(this.workflowId).subscribe({
next: (workflow) => {
this.form.patchValue({
name: workflow.name,
description: workflow.description || '',
requestType: workflow.requestType,
});
this.stagesArray.clear();
workflow.stages.forEach((stage) => {
this.stagesArray.push(
this.fb.group({
id: [stage.id],
name: [stage.name, Validators.required],
departmentId: [stage.departmentId, Validators.required],
order: [stage.order],
isRequired: [stage.isRequired],
})
);
});
this.loading.set(false);
},
error: () => {
this.notification.error('Failed to load workflow');
this.router.navigate(['/workflows']);
},
});
}
private createStageGroup() {
return this.fb.group({
id: [''],
name: ['', Validators.required],
departmentId: ['', Validators.required],
order: [1],
isRequired: [true],
});
}
addStage(): void {
const order = this.stagesArray.length + 1;
const group = this.createStageGroup();
group.patchValue({ order });
this.stagesArray.push(group);
}
removeStage(index: number): void {
if (this.stagesArray.length > 1) {
this.stagesArray.removeAt(index);
this.updateStageOrders();
}
}
private updateStageOrders(): void {
this.stagesArray.controls.forEach((control, index) => {
control.patchValue({ order: index + 1 });
});
}
onSubmit(): void {
if (this.form.invalid) return;
this.submitting.set(true);
const values = this.form.getRawValue();
const dto = {
name: values.name!,
description: values.description || undefined,
requestType: values.requestType!,
stages: values.stages.map((s, i) => ({
id: s.id || `stage-${i + 1}`,
name: s.name || `Stage ${i + 1}`,
departmentId: s.departmentId || '',
isRequired: s.isRequired ?? true,
order: i + 1,
})),
};
const action$ = this.isEditMode()
? this.workflowService.updateWorkflow(this.workflowId!, dto)
: this.workflowService.createWorkflow(dto);
action$.subscribe({
next: (result) => {
this.notification.success(
this.isEditMode() ? 'Workflow updated' : 'Workflow created'
);
this.router.navigate(['/workflows', result.id]);
},
error: () => {
this.submitting.set(false);
},
});
}
}

View File

@@ -0,0 +1,197 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import { MatCardModule } from '@angular/material/card';
import { MatTableModule } from '@angular/material/table';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatChipsModule } from '@angular/material/chips';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatTooltipModule } from '@angular/material/tooltip';
import { PageHeaderComponent } from '../../../shared/components/page-header/page-header.component';
import { StatusBadgeComponent } from '../../../shared/components/status-badge/status-badge.component';
import { EmptyStateComponent } from '../../../shared/components/empty-state/empty-state.component';
import { WorkflowService } from '../services/workflow.service';
import { WorkflowResponseDto } from '../../../api/models';
@Component({
selector: 'app-workflow-list',
standalone: true,
imports: [
CommonModule,
RouterModule,
MatCardModule,
MatTableModule,
MatPaginatorModule,
MatButtonModule,
MatIconModule,
MatChipsModule,
MatProgressSpinnerModule,
MatTooltipModule,
PageHeaderComponent,
StatusBadgeComponent,
EmptyStateComponent,
],
template: `
<div class="page-container">
<app-page-header title="Workflows" subtitle="Manage approval workflows">
<button mat-stroked-button routerLink="new" class="header-btn">
<mat-icon>add</mat-icon>
Form Builder
</button>
<button mat-raised-button color="primary" routerLink="builder" class="header-btn">
<mat-icon>account_tree</mat-icon>
Visual Builder
</button>
</app-page-header>
<mat-card>
<mat-card-content>
@if (loading()) {
<div class="loading-container">
<mat-spinner diameter="48"></mat-spinner>
</div>
} @else if (workflows().length === 0) {
<app-empty-state
icon="account_tree"
title="No workflows"
message="No approval workflows have been created yet."
>
<button mat-raised-button color="primary" routerLink="new">
<mat-icon>add</mat-icon>
Create Workflow
</button>
</app-empty-state>
} @else {
<table mat-table [dataSource]="workflows()">
<ng-container matColumnDef="name">
<th mat-header-cell *matHeaderCellDef>Name</th>
<td mat-cell *matCellDef="let row">
<a [routerLink]="[row.id]" class="workflow-link">{{ row.name }}</a>
</td>
</ng-container>
<ng-container matColumnDef="requestType">
<th mat-header-cell *matHeaderCellDef>Request Type</th>
<td mat-cell *matCellDef="let row">{{ formatType(row.requestType) }}</td>
</ng-container>
<ng-container matColumnDef="stages">
<th mat-header-cell *matHeaderCellDef>Stages</th>
<td mat-cell *matCellDef="let row">
<mat-chip>{{ row.stages?.length || 0 }} stages</mat-chip>
</td>
</ng-container>
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef>Status</th>
<td mat-cell *matCellDef="let row">
<app-status-badge [status]="row.isActive ? 'ACTIVE' : 'INACTIVE'" />
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef></th>
<td mat-cell *matCellDef="let row">
<button mat-icon-button [routerLink]="[row.id]" matTooltip="Preview">
<mat-icon>visibility</mat-icon>
</button>
<button mat-icon-button [routerLink]="['builder', row.id]" matTooltip="Visual Editor">
<mat-icon>account_tree</mat-icon>
</button>
<button mat-icon-button [routerLink]="[row.id, 'edit']" matTooltip="Form Editor">
<mat-icon>edit</mat-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
</table>
<mat-paginator
[length]="totalItems()"
[pageSize]="pageSize()"
[pageIndex]="pageIndex()"
[pageSizeOptions]="[5, 10, 25]"
(page)="onPageChange($event)"
showFirstLastButtons
/>
}
</mat-card-content>
</mat-card>
</div>
`,
styles: [
`
.loading-container {
display: flex;
justify-content: center;
padding: 48px;
}
table {
width: 100%;
}
.workflow-link {
color: var(--dbim-blue-mid, #2563EB);
text-decoration: none;
font-weight: 500;
&:hover {
text-decoration: underline;
}
}
.mat-column-actions {
width: 140px;
text-align: right;
}
.header-btn {
margin-left: 8px;
}
`,
],
})
export class WorkflowListComponent implements OnInit {
private readonly workflowService = inject(WorkflowService);
readonly loading = signal(true);
readonly workflows = signal<WorkflowResponseDto[]>([]);
readonly totalItems = signal(0);
readonly pageSize = signal(10);
readonly pageIndex = signal(0);
readonly displayedColumns = ['name', 'requestType', 'stages', 'status', 'actions'];
ngOnInit(): void {
this.loadWorkflows();
}
loadWorkflows(): void {
this.loading.set(true);
this.workflowService.getWorkflows(this.pageIndex() + 1, this.pageSize()).subscribe({
next: (response) => {
this.workflows.set(response.data);
this.totalItems.set(response.total);
this.loading.set(false);
},
error: () => {
this.loading.set(false);
},
});
}
onPageChange(event: PageEvent): void {
this.pageIndex.set(event.pageIndex);
this.pageSize.set(event.pageSize);
this.loadWorkflows();
}
formatType(type: string): string {
return type.replace(/_/g, ' ');
}
}

View File

@@ -0,0 +1,309 @@
import { Component, OnInit, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { MatCardModule } from '@angular/material/card';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatChipsModule } from '@angular/material/chips';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { PageHeaderComponent } from '../../../shared/components/page-header/page-header.component';
import { StatusBadgeComponent } from '../../../shared/components/status-badge/status-badge.component';
import { ConfirmDialogComponent } from '../../../shared/components/confirm-dialog/confirm-dialog.component';
import { WorkflowService } from '../services/workflow.service';
import { NotificationService } from '../../../core/services/notification.service';
import { WorkflowResponseDto } from '../../../api/models';
@Component({
selector: 'app-workflow-preview',
standalone: true,
imports: [
CommonModule,
RouterModule,
MatCardModule,
MatButtonModule,
MatIconModule,
MatChipsModule,
MatProgressSpinnerModule,
MatDialogModule,
PageHeaderComponent,
StatusBadgeComponent,
],
template: `
<div class="page-container">
@if (loading()) {
<div class="loading-container">
<mat-spinner diameter="48"></mat-spinner>
</div>
} @else if (workflow(); as wf) {
<app-page-header [title]="wf.name" [subtitle]="wf.description || 'Workflow configuration'">
<button mat-button routerLink="/workflows">
<mat-icon>arrow_back</mat-icon>
Back
</button>
<button mat-raised-button [routerLink]="['edit']">
<mat-icon>edit</mat-icon>
Edit
</button>
</app-page-header>
<div class="workflow-info">
<mat-card class="info-card">
<mat-card-content>
<div class="info-grid">
<div class="info-item">
<span class="label">Status</span>
<app-status-badge [status]="wf.isActive ? 'ACTIVE' : 'INACTIVE'" />
</div>
<div class="info-item">
<span class="label">Request Type</span>
<span class="value">{{ formatType(wf.requestType) }}</span>
</div>
<div class="info-item">
<span class="label">Total Stages</span>
<span class="value">{{ wf.stages.length || 0 }}</span>
</div>
<div class="info-item">
<span class="label">Created</span>
<span class="value">{{ wf.createdAt | date: 'medium' }}</span>
</div>
</div>
</mat-card-content>
</mat-card>
</div>
<div class="stages-section">
<h3>Approval Stages</h3>
<div class="stages-flow">
@for (stage of wf.stages; track stage.id; let i = $index; let last = $last) {
<div class="stage-item">
<div class="stage-number">{{ i + 1 }}</div>
<mat-card class="stage-card">
<div class="stage-content">
<div class="stage-name">{{ stage.name }}</div>
<div class="stage-dept">{{ stage.departmentId }}</div>
@if (stage.isRequired) {
<mat-chip>Required</mat-chip>
}
</div>
</mat-card>
@if (!last) {
<div class="stage-connector">
<mat-icon>arrow_downward</mat-icon>
</div>
}
</div>
}
</div>
</div>
<div class="actions-section">
<button
mat-stroked-button
[color]="wf.isActive ? 'warn' : 'primary'"
(click)="toggleActive()"
>
<mat-icon>{{ wf.isActive ? 'block' : 'check_circle' }}</mat-icon>
{{ wf.isActive ? 'Deactivate' : 'Activate' }}
</button>
<button mat-stroked-button color="warn" (click)="deleteWorkflow()">
<mat-icon>delete</mat-icon>
Delete Workflow
</button>
</div>
}
</div>
`,
styles: [
`
.loading-container {
display: flex;
justify-content: center;
padding: 48px;
}
.workflow-info {
margin-bottom: 32px;
}
.info-card mat-card-content {
padding: 0;
}
.info-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1px;
background-color: #eee;
}
.info-item {
padding: 16px;
background-color: white;
display: flex;
flex-direction: column;
gap: 8px;
}
.label {
font-size: 0.75rem;
color: rgba(0, 0, 0, 0.54);
text-transform: uppercase;
}
.value {
font-weight: 500;
}
.stages-section {
margin-bottom: 32px;
h3 {
margin: 0 0 24px;
font-size: 1.25rem;
font-weight: 500;
}
}
.stages-flow {
display: flex;
flex-direction: column;
align-items: center;
}
.stage-item {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
max-width: 400px;
}
.stage-number {
width: 32px;
height: 32px;
border-radius: 50%;
background-color: #1976d2;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
margin-bottom: 8px;
}
.stage-card {
width: 100%;
padding: 16px;
}
.stage-content {
text-align: center;
}
.stage-name {
font-weight: 500;
font-size: 1.125rem;
margin-bottom: 4px;
}
.stage-dept {
color: rgba(0, 0, 0, 0.54);
font-size: 0.875rem;
margin-bottom: 8px;
}
.stage-connector {
padding: 8px 0;
color: rgba(0, 0, 0, 0.26);
}
.actions-section {
display: flex;
gap: 12px;
padding-top: 24px;
border-top: 1px solid #eee;
}
@media (max-width: 768px) {
.info-grid {
grid-template-columns: repeat(2, 1fr);
}
}
`,
],
})
export class WorkflowPreviewComponent implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly workflowService = inject(WorkflowService);
private readonly notification = inject(NotificationService);
private readonly dialog = inject(MatDialog);
readonly loading = signal(true);
readonly workflow = signal<WorkflowResponseDto | null>(null);
ngOnInit(): void {
this.loadWorkflow();
}
private loadWorkflow(): void {
const id = this.route.snapshot.paramMap.get('id');
if (!id) {
this.router.navigate(['/workflows']);
return;
}
this.workflowService.getWorkflow(id).subscribe({
next: (wf) => {
this.workflow.set(wf);
this.loading.set(false);
},
error: () => {
this.notification.error('Workflow not found');
this.router.navigate(['/workflows']);
},
});
}
formatType(type: string): string {
return type.replace(/_/g, ' ');
}
toggleActive(): void {
const wf = this.workflow();
if (!wf) return;
this.workflowService.toggleActive(wf.id, !wf.isActive).subscribe({
next: () => {
this.notification.success(wf.isActive ? 'Workflow deactivated' : 'Workflow activated');
this.loadWorkflow();
},
});
}
deleteWorkflow(): void {
const wf = this.workflow();
if (!wf) return;
const dialogRef = this.dialog.open(ConfirmDialogComponent, {
data: {
title: 'Delete Workflow',
message: `Are you sure you want to delete "${wf.name}"? This cannot be undone.`,
confirmText: 'Delete',
confirmColor: 'warn',
},
});
dialogRef.afterClosed().subscribe((confirmed) => {
if (confirmed) {
this.workflowService.deleteWorkflow(wf.id).subscribe({
next: () => {
this.notification.success('Workflow deleted');
this.router.navigate(['/workflows']);
},
});
}
});
}
}

View File

@@ -0,0 +1,47 @@
import { Routes } from '@angular/router';
import { adminGuard } from '../../core/guards';
export const WORKFLOWS_ROUTES: Routes = [
{
path: '',
loadComponent: () =>
import('./workflow-list/workflow-list.component').then((m) => m.WorkflowListComponent),
canActivate: [adminGuard],
},
{
path: 'new',
loadComponent: () =>
import('./workflow-form/workflow-form.component').then((m) => m.WorkflowFormComponent),
canActivate: [adminGuard],
},
{
path: 'builder',
loadComponent: () =>
import('./workflow-builder/workflow-builder.component').then(
(m) => m.WorkflowBuilderComponent
),
canActivate: [adminGuard],
},
{
path: 'builder/:id',
loadComponent: () =>
import('./workflow-builder/workflow-builder.component').then(
(m) => m.WorkflowBuilderComponent
),
canActivate: [adminGuard],
},
{
path: ':id',
loadComponent: () =>
import('./workflow-preview/workflow-preview.component').then(
(m) => m.WorkflowPreviewComponent
),
canActivate: [adminGuard],
},
{
path: ':id/edit',
loadComponent: () =>
import('./workflow-form/workflow-form.component').then((m) => m.WorkflowFormComponent),
canActivate: [adminGuard],
},
];