diff --git a/src-ui/src/app/components/app-frame/ai-status-indicator/ai-status-indicator.component.html b/src-ui/src/app/components/app-frame/ai-status-indicator/ai-status-indicator.component.html new file mode 100644 index 000000000..89385cf2e --- /dev/null +++ b/src-ui/src/app/components/app-frame/ai-status-indicator/ai-status-indicator.component.html @@ -0,0 +1,77 @@ + + + +
+
+ + AI Scanner Status +
+ +
+
+ Status: + + {{ aiStatus.active ? 'Active' : 'Inactive' }} + @if (aiStatus.processing) { + Processing + } + +
+ + @if (aiStatus.active) { +
+ Scanned Today: + {{ aiStatus.documents_scanned_today }} +
+ +
+ Suggestions Applied: + {{ aiStatus.suggestions_applied }} +
+ + @if (aiStatus.pending_deletion_requests > 0) { +
+ Pending Deletions: + {{ aiStatus.pending_deletion_requests }} +
+ } + + @if (aiStatus.last_scan) { +
+ Last Scan: + {{ aiStatus.last_scan | date: 'short' }} +
+ } + } +
+ +
+ +
+
+
diff --git a/src-ui/src/app/components/app-frame/ai-status-indicator/ai-status-indicator.component.scss b/src-ui/src/app/components/app-frame/ai-status-indicator/ai-status-indicator.component.scss new file mode 100644 index 000000000..36a090fad --- /dev/null +++ b/src-ui/src/app/components/app-frame/ai-status-indicator/ai-status-indicator.component.scss @@ -0,0 +1,55 @@ +.ai-status-container { + display: flex; + align-items: center; +} + +.ai-status-icon { + transition: all 0.3s ease; + + &.inactive { + opacity: 0.4; + color: var(--bs-secondary); + } + + &.active { + color: var(--bs-success); + } + + &.processing { + color: var(--bs-primary); + animation: pulse 1.5s ease-in-out infinite; + } +} + +@keyframes pulse { + 0%, 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.7; + transform: scale(1.1); + } +} + +.ai-status-tooltip { + min-width: 250px; + + .status-row { + display: flex; + justify-content: space-between; + align-items: center; + + .status-label { + font-weight: 500; + margin-right: 0.5rem; + } + } +} + +// Badge positioning +.badge.rounded-pill { + font-size: 0.65rem; + padding: 0.15rem 0.35rem; + min-width: 1.2rem; +} diff --git a/src-ui/src/app/components/app-frame/ai-status-indicator/ai-status-indicator.component.spec.ts b/src-ui/src/app/components/app-frame/ai-status-indicator/ai-status-indicator.component.spec.ts new file mode 100644 index 000000000..7a2bc8bb5 --- /dev/null +++ b/src-ui/src/app/components/app-frame/ai-status-indicator/ai-status-indicator.component.spec.ts @@ -0,0 +1,104 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing' +import { Router } from '@angular/router' +import { of } from 'rxjs' +import { AIStatus } from 'src/app/data/ai-status' +import { AIStatusService } from 'src/app/services/ai-status.service' +import { AIStatusIndicatorComponent } from './ai-status-indicator.component' + +describe('AIStatusIndicatorComponent', () => { + let component: AIStatusIndicatorComponent + let fixture: ComponentFixture + let aiStatusService: jasmine.SpyObj + let router: jasmine.SpyObj + + const mockAIStatus: AIStatus = { + active: true, + processing: false, + documents_scanned_today: 42, + suggestions_applied: 15, + pending_deletion_requests: 2, + last_scan: '2025-11-15T12:00:00Z', + version: '1.0.0', + } + + beforeEach(async () => { + const aiStatusServiceSpy = jasmine.createSpyObj('AIStatusService', [ + 'getStatus', + 'getCurrentStatus', + 'refresh', + ]) + const routerSpy = jasmine.createSpyObj('Router', ['navigate']) + + aiStatusServiceSpy.getStatus.and.returnValue(of(mockAIStatus)) + aiStatusServiceSpy.getCurrentStatus.and.returnValue(mockAIStatus) + + await TestBed.configureTestingModule({ + imports: [AIStatusIndicatorComponent], + providers: [ + { provide: AIStatusService, useValue: aiStatusServiceSpy }, + { provide: Router, useValue: routerSpy }, + ], + }).compileComponents() + + aiStatusService = TestBed.inject( + AIStatusService + ) as jasmine.SpyObj + router = TestBed.inject(Router) as jasmine.SpyObj + + fixture = TestBed.createComponent(AIStatusIndicatorComponent) + component = fixture.componentInstance + fixture.detectChanges() + }) + + it('should create', () => { + expect(component).toBeTruthy() + }) + + it('should subscribe to AI status on init', () => { + expect(aiStatusService.getStatus).toHaveBeenCalled() + expect(component.aiStatus).toEqual(mockAIStatus) + }) + + it('should show robot icon', () => { + expect(component.iconName).toBe('robot') + }) + + it('should have active class when AI is active', () => { + component.aiStatus = { ...mockAIStatus, active: true, processing: false } + expect(component.iconClass).toContain('active') + }) + + it('should have inactive class when AI is inactive', () => { + component.aiStatus = { ...mockAIStatus, active: false } + expect(component.iconClass).toContain('inactive') + }) + + it('should have processing class when AI is processing', () => { + component.aiStatus = { ...mockAIStatus, active: true, processing: true } + expect(component.iconClass).toContain('processing') + }) + + it('should show alerts when there are pending deletion requests', () => { + component.aiStatus = { ...mockAIStatus, pending_deletion_requests: 2 } + expect(component.hasAlerts).toBe(true) + }) + + it('should not show alerts when there are no pending deletion requests', () => { + component.aiStatus = { ...mockAIStatus, pending_deletion_requests: 0 } + expect(component.hasAlerts).toBe(false) + }) + + it('should navigate to settings when navigateToSettings is called', () => { + component.navigateToSettings() + expect(router.navigate).toHaveBeenCalledWith(['/settings'], { + fragment: 'ai-scanner', + }) + }) + + it('should unsubscribe on destroy', () => { + const subscription = component['subscription'] + spyOn(subscription, 'unsubscribe') + component.ngOnDestroy() + expect(subscription.unsubscribe).toHaveBeenCalled() + }) +}) diff --git a/src-ui/src/app/components/app-frame/ai-status-indicator/ai-status-indicator.component.ts b/src-ui/src/app/components/app-frame/ai-status-indicator/ai-status-indicator.component.ts new file mode 100644 index 000000000..ea8da287d --- /dev/null +++ b/src-ui/src/app/components/app-frame/ai-status-indicator/ai-status-indicator.component.ts @@ -0,0 +1,94 @@ +import { DatePipe } from '@angular/common' +import { Component, OnDestroy, OnInit, inject } from '@angular/core' +import { Router, RouterModule } from '@angular/router' +import { + NgbPopoverModule, + NgbTooltipModule, +} from '@ng-bootstrap/ng-bootstrap' +import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' +import { Subscription } from 'rxjs' +import { AIStatus } from 'src/app/data/ai-status' +import { AIStatusService } from 'src/app/services/ai-status.service' + +@Component({ + selector: 'pngx-ai-status-indicator', + templateUrl: './ai-status-indicator.component.html', + styleUrls: ['./ai-status-indicator.component.scss'], + imports: [ + DatePipe, + NgbPopoverModule, + NgbTooltipModule, + NgxBootstrapIconsModule, + RouterModule, + ], +}) +export class AIStatusIndicatorComponent implements OnInit, OnDestroy { + private aiStatusService = inject(AIStatusService) + private router = inject(Router) + + private subscription: Subscription + + public aiStatus: AIStatus = { + active: false, + processing: false, + documents_scanned_today: 0, + suggestions_applied: 0, + pending_deletion_requests: 0, + } + + ngOnInit(): void { + this.subscription = this.aiStatusService + .getStatus() + .subscribe((status) => { + this.aiStatus = status + }) + } + + ngOnDestroy(): void { + this.subscription?.unsubscribe() + } + + /** + * Get the appropriate icon name based on AI status + */ + get iconName(): string { + if (!this.aiStatus.active) { + return 'robot' // Inactive + } + if (this.aiStatus.processing) { + return 'robot' // Processing (will add animation via CSS) + } + return 'robot' // Active + } + + /** + * Get the CSS class for the icon based on status + */ + get iconClass(): string { + const classes = ['ai-status-icon'] + + if (!this.aiStatus.active) { + classes.push('inactive') + } else if (this.aiStatus.processing) { + classes.push('processing') + } else { + classes.push('active') + } + + return classes.join(' ') + } + + /** + * Navigate to AI configuration settings + */ + navigateToSettings(): void { + this.router.navigate(['/settings'], { fragment: 'ai-scanner' }) + } + + /** + * Check if there are any alerts (pending deletion requests) + */ + get hasAlerts(): boolean { + return this.aiStatus.pending_deletion_requests > 0 + } +} diff --git a/src-ui/src/app/components/app-frame/app-frame.component.html b/src-ui/src/app/components/app-frame/app-frame.component.html index 673eaf03b..afb39216f 100644 --- a/src-ui/src/app/components/app-frame/app-frame.component.html +++ b/src-ui/src/app/components/app-frame/app-frame.component.html @@ -31,6 +31,7 @@
    +