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:
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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']);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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, ' ');
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
47
frontend/src/app/features/workflows/workflows.routes.ts
Normal file
47
frontend/src/app/features/workflows/workflows.routes.ts
Normal 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],
|
||||
},
|
||||
];
|
||||
Reference in New Issue
Block a user