Changes before error encountered

Co-authored-by: dawnsystem <42047891+dawnsystem@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2025-11-12 15:37:44 +00:00
parent 780decf543
commit 5695d41903
5 changed files with 1111 additions and 0 deletions

View file

@ -0,0 +1,126 @@
@if (hasSuggestions) {
<div class="ai-suggestions-panel card shadow-sm mb-3" [@slideIn]>
<div class="card-header bg-primary text-white d-flex align-items-center justify-content-between" role="button" (click)="toggleCollapse()">
<div class="d-flex align-items-center">
<i-bs name="magic" width="1.2em" height="1.2em" class="me-2"></i-bs>
<strong i18n>AI Suggestions</strong>
<span class="badge bg-light text-primary ms-2">{{ pendingSuggestions.length }}</span>
</div>
<div class="d-flex align-items-center gap-2">
@if (appliedCount > 0) {
<span class="badge bg-success" i18n>{{ appliedCount }} applied</span>
}
@if (rejectedCount > 0) {
<span class="badge bg-danger" i18n>{{ rejectedCount }} rejected</span>
}
<i-bs [name]="isCollapsed ? 'chevron-down' : 'chevron-up'" width="1em" height="1em"></i-bs>
</div>
</div>
<div [ngbCollapse]="isCollapsed" class="card-body">
<div class="mb-3 pb-2 border-bottom">
<p class="text-muted small mb-2" i18n>
<i-bs name="info-circle" width="0.9em" height="0.9em" class="me-1"></i-bs>
AI has analyzed this document and suggests the following metadata. Review and apply or reject each suggestion.
</p>
<div class="d-flex gap-2 flex-wrap">
<button
type="button"
class="btn btn-sm btn-success"
[disabled]="disabled || pendingSuggestions.length === 0"
(click)="applyAll()"
i18n>
<i-bs name="check-all" width="1em" height="1em" class="me-1"></i-bs>
Apply All
</button>
<button
type="button"
class="btn btn-sm btn-outline-danger"
[disabled]="disabled || pendingSuggestions.length === 0"
(click)="rejectAll()"
i18n>
<i-bs name="x-circle" width="1em" height="1em" class="me-1"></i-bs>
Reject All
</button>
</div>
</div>
<div class="suggestions-container">
@for (type of suggestionTypes; track type) {
<div class="suggestion-group mb-3">
<div class="suggestion-group-header d-flex align-items-center mb-2">
<i-bs [name]="getTypeIcon(type)" width="1.1em" height="1.1em" class="me-2 text-primary"></i-bs>
<strong class="text-secondary">{{ getTypeLabel(type) }}</strong>
<span class="badge bg-secondary ms-2">{{ groupedSuggestions.get(type)?.length }}</span>
</div>
<div class="suggestion-items">
@for (suggestion of groupedSuggestions.get(type); track suggestion.id) {
<div
class="suggestion-item card mb-2"
[@fadeInOut]
[class.suggestion-applying]="suggestion.status === AISuggestionStatus.Applied"
[class.suggestion-rejecting]="suggestion.status === AISuggestionStatus.Rejected">
<div class="card-body p-2">
<div class="d-flex align-items-start justify-content-between gap-2">
<div class="flex-grow-1">
<div class="suggestion-value fw-medium mb-1">
@if (suggestion.type === AISuggestionType.CustomField && suggestion.field_name) {
<span class="text-muted small me-1">{{ suggestion.field_name }}:</span>
}
{{ getLabel(suggestion) }}
</div>
<div class="d-flex align-items-center gap-2 flex-wrap">
<span
class="confidence-badge badge"
[ngClass]="getConfidenceClass(suggestion.confidence)">
<i-bs [name]="getConfidenceIcon(suggestion.confidence)" width="0.8em" height="0.8em" class="me-1"></i-bs>
{{ getConfidenceLabel(suggestion.confidence) }}
</span>
@if (suggestion.created_at) {
<span class="text-muted small">
<i-bs name="clock" width="0.8em" height="0.8em" class="me-1"></i-bs>
{{ suggestion.created_at | date:'short' }}
</span>
}
</div>
</div>
<div class="suggestion-actions d-flex gap-1 flex-shrink-0">
<button
type="button"
class="btn btn-sm btn-success"
[disabled]="disabled"
(click)="applySuggestion(suggestion)"
i18n-title
title="Apply this suggestion">
<i-bs name="check-lg" width="1em" height="1em"></i-bs>
</button>
<button
type="button"
class="btn btn-sm btn-outline-danger"
[disabled]="disabled"
(click)="rejectSuggestion(suggestion)"
i18n-title
title="Reject this suggestion">
<i-bs name="x-lg" width="1em" height="1em"></i-bs>
</button>
</div>
</div>
</div>
</div>
}
</div>
</div>
}
</div>
@if (pendingSuggestions.length === 0) {
<div class="text-center text-muted py-3">
<i-bs name="check-circle" width="2em" height="2em" class="mb-2"></i-bs>
<p i18n>All suggestions have been processed</p>
</div>
}
</div>
</div>
}

View file

@ -0,0 +1,241 @@
.ai-suggestions-panel {
border: 2px solid var(--bs-primary);
border-radius: 0.5rem;
overflow: hidden;
transition: all 0.3s ease;
&:hover {
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;
}
.card-header {
cursor: pointer;
user-select: none;
transition: background-color 0.2s ease;
padding: 0.75rem 1rem;
&:hover {
background-color: var(--bs-primary) !important;
filter: brightness(1.1);
}
.badge {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
}
}
.card-body {
padding: 1rem;
}
}
.suggestions-container {
max-height: 600px;
overflow-y: auto;
overflow-x: hidden;
// Custom scrollbar styles
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
&::-webkit-scrollbar-thumb {
background: #888;
border-radius: 4px;
&:hover {
background: #555;
}
}
}
.suggestion-group {
.suggestion-group-header {
padding-bottom: 0.5rem;
border-bottom: 1px solid #dee2e6;
strong {
font-size: 0.95rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.badge {
font-size: 0.7rem;
}
}
.suggestion-items {
padding-left: 1.5rem;
}
}
.suggestion-item {
border-left: 3px solid var(--bs-primary);
transition: all 0.3s ease;
position: relative;
&:hover {
border-left-color: var(--bs-success);
box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.1);
transform: translateX(2px);
}
&.suggestion-applying {
animation: applyAnimation 0.5s ease;
border-left-color: var(--bs-success);
background-color: rgba(25, 135, 84, 0.1);
}
&.suggestion-rejecting {
animation: rejectAnimation 0.5s ease;
border-left-color: var(--bs-danger);
background-color: rgba(220, 53, 69, 0.1);
}
.suggestion-value {
color: #333;
font-size: 0.95rem;
word-break: break-word;
}
.confidence-badge {
font-size: 0.75rem;
font-weight: 500;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
display: inline-flex;
align-items: center;
&.confidence-high {
background-color: #28a745;
color: white;
}
&.confidence-medium {
background-color: #ffc107;
color: #333;
}
&.confidence-low {
background-color: #dc3545;
color: white;
}
}
.suggestion-actions {
.btn {
min-width: 36px;
padding: 0.25rem 0.5rem;
transition: all 0.2s ease;
&:hover {
transform: scale(1.1);
}
&:active {
transform: scale(0.95);
}
}
}
}
// Animations
@keyframes applyAnimation {
0% {
opacity: 1;
transform: translateX(0);
}
50% {
opacity: 0.5;
transform: translateX(20px);
}
100% {
opacity: 0;
transform: translateX(40px);
}
}
@keyframes rejectAnimation {
0% {
opacity: 1;
transform: translateX(0) rotate(0deg);
}
50% {
opacity: 0.5;
transform: translateX(-20px) rotate(-5deg);
}
100% {
opacity: 0;
transform: translateX(-40px) rotate(-10deg);
}
}
// Responsive design
@media (max-width: 768px) {
.ai-suggestions-panel {
.card-header {
padding: 0.5rem 0.75rem;
flex-wrap: wrap;
.badge {
font-size: 0.65rem;
padding: 0.2rem 0.4rem;
}
}
.card-body {
padding: 0.75rem;
}
}
.suggestions-container {
max-height: 400px;
}
.suggestion-group {
.suggestion-items {
padding-left: 0.5rem;
}
}
.suggestion-item {
.d-flex {
flex-direction: column;
gap: 0.5rem !important;
}
.suggestion-actions {
width: 100%;
justify-content: flex-end;
}
}
}
@media (max-width: 576px) {
.ai-suggestions-panel {
.card-header {
.d-flex {
flex-direction: column;
align-items: flex-start !important;
gap: 0.5rem;
}
}
}
.suggestion-item {
.suggestion-value {
font-size: 0.875rem;
}
.confidence-badge {
font-size: 0.7rem;
}
}
}

View file

@ -0,0 +1,331 @@
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { provideAnimations } from '@angular/platform-browser/animations'
import { NgbCollapseModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { of } from 'rxjs'
import {
AISuggestion,
AISuggestionStatus,
AISuggestionType,
} from 'src/app/data/ai-suggestion'
import { Correspondent } from 'src/app/data/correspondent'
import { DocumentType } from 'src/app/data/document-type'
import { StoragePath } from 'src/app/data/storage-path'
import { Tag } from 'src/app/data/tag'
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { TagService } from 'src/app/services/rest/tag.service'
import { ToastService } from 'src/app/services/toast.service'
import { AiSuggestionsPanelComponent } from './ai-suggestions-panel.component'
const mockTags: Tag[] = [
{ id: 1, name: 'Invoice', colour: '#ff0000', text_colour: '#ffffff' },
{ id: 2, name: 'Receipt', colour: '#00ff00', text_colour: '#000000' },
]
const mockCorrespondents: Correspondent[] = [
{ id: 1, name: 'Acme Corp' },
{ id: 2, name: 'TechStart LLC' },
]
const mockDocumentTypes: DocumentType[] = [
{ id: 1, name: 'Invoice' },
{ id: 2, name: 'Contract' },
]
const mockStoragePaths: StoragePath[] = [
{ id: 1, name: '/invoices', path: '/invoices' },
{ id: 2, name: '/contracts', path: '/contracts' },
]
const mockSuggestions: AISuggestion[] = [
{
id: '1',
type: AISuggestionType.Tag,
value: 1,
confidence: 0.85,
status: AISuggestionStatus.Pending,
},
{
id: '2',
type: AISuggestionType.Correspondent,
value: 1,
confidence: 0.75,
status: AISuggestionStatus.Pending,
},
{
id: '3',
type: AISuggestionType.DocumentType,
value: 1,
confidence: 0.90,
status: AISuggestionStatus.Pending,
},
]
describe('AiSuggestionsPanelComponent', () => {
let component: AiSuggestionsPanelComponent
let fixture: ComponentFixture<AiSuggestionsPanelComponent>
let tagService: TagService
let correspondentService: CorrespondentService
let documentTypeService: DocumentTypeService
let storagePathService: StoragePathService
let customFieldsService: CustomFieldsService
let toastService: ToastService
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
AiSuggestionsPanelComponent,
NgbCollapseModule,
NgxBootstrapIconsModule.pick(allIcons),
],
providers: [
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
provideAnimations(),
],
}).compileComponents()
tagService = TestBed.inject(TagService)
correspondentService = TestBed.inject(CorrespondentService)
documentTypeService = TestBed.inject(DocumentTypeService)
storagePathService = TestBed.inject(StoragePathService)
customFieldsService = TestBed.inject(CustomFieldsService)
toastService = TestBed.inject(ToastService)
jest.spyOn(tagService, 'listAll').mockReturnValue(
of({
all: mockTags.map((t) => t.id),
count: mockTags.length,
results: mockTags,
})
)
jest.spyOn(correspondentService, 'listAll').mockReturnValue(
of({
all: mockCorrespondents.map((c) => c.id),
count: mockCorrespondents.length,
results: mockCorrespondents,
})
)
jest.spyOn(documentTypeService, 'listAll').mockReturnValue(
of({
all: mockDocumentTypes.map((dt) => dt.id),
count: mockDocumentTypes.length,
results: mockDocumentTypes,
})
)
jest.spyOn(storagePathService, 'listAll').mockReturnValue(
of({
all: mockStoragePaths.map((sp) => sp.id),
count: mockStoragePaths.length,
results: mockStoragePaths,
})
)
jest.spyOn(customFieldsService, 'listAll').mockReturnValue(
of({
all: [],
count: 0,
results: [],
})
)
fixture = TestBed.createComponent(AiSuggestionsPanelComponent)
component = fixture.componentInstance
})
it('should create', () => {
expect(component).toBeTruthy()
})
it('should process suggestions on input change', () => {
component.suggestions = mockSuggestions
component.ngOnChanges({
suggestions: {
currentValue: mockSuggestions,
previousValue: [],
firstChange: true,
isFirstChange: () => true,
},
})
expect(component.pendingSuggestions.length).toBe(3)
expect(component.appliedCount).toBe(0)
expect(component.rejectedCount).toBe(0)
})
it('should group suggestions by type', () => {
component.suggestions = mockSuggestions
component.ngOnChanges({
suggestions: {
currentValue: mockSuggestions,
previousValue: [],
firstChange: true,
isFirstChange: () => true,
},
})
expect(component.groupedSuggestions.size).toBe(3)
expect(component.groupedSuggestions.get(AISuggestionType.Tag)?.length).toBe(
1
)
expect(
component.groupedSuggestions.get(AISuggestionType.Correspondent)?.length
).toBe(1)
expect(
component.groupedSuggestions.get(AISuggestionType.DocumentType)?.length
).toBe(1)
})
it('should apply a suggestion', () => {
component.suggestions = mockSuggestions
component.ngOnChanges({
suggestions: {
currentValue: mockSuggestions,
previousValue: [],
firstChange: true,
isFirstChange: () => true,
},
})
const toastSpy = jest.spyOn(toastService, 'showInfo')
const applySpy = jest.spyOn(component.apply, 'emit')
const suggestion = component.pendingSuggestions[0]
component.applySuggestion(suggestion)
expect(suggestion.status).toBe(AISuggestionStatus.Applied)
expect(applySpy).toHaveBeenCalledWith(suggestion)
expect(toastSpy).toHaveBeenCalled()
})
it('should reject a suggestion', () => {
component.suggestions = mockSuggestions
component.ngOnChanges({
suggestions: {
currentValue: mockSuggestions,
previousValue: [],
firstChange: true,
isFirstChange: () => true,
},
})
const toastSpy = jest.spyOn(toastService, 'showInfo')
const rejectSpy = jest.spyOn(component.reject, 'emit')
const suggestion = component.pendingSuggestions[0]
component.rejectSuggestion(suggestion)
expect(suggestion.status).toBe(AISuggestionStatus.Rejected)
expect(rejectSpy).toHaveBeenCalledWith(suggestion)
expect(toastSpy).toHaveBeenCalled()
})
it('should apply all suggestions', () => {
component.suggestions = mockSuggestions
component.ngOnChanges({
suggestions: {
currentValue: mockSuggestions,
previousValue: [],
firstChange: true,
isFirstChange: () => true,
},
})
const toastSpy = jest.spyOn(toastService, 'showInfo')
const applySpy = jest.spyOn(component.apply, 'emit')
component.applyAll()
expect(applySpy).toHaveBeenCalledTimes(3)
expect(toastSpy).toHaveBeenCalled()
})
it('should reject all suggestions', () => {
component.suggestions = mockSuggestions
component.ngOnChanges({
suggestions: {
currentValue: mockSuggestions,
previousValue: [],
firstChange: true,
isFirstChange: () => true,
},
})
const toastSpy = jest.spyOn(toastService, 'showInfo')
const rejectSpy = jest.spyOn(component.reject, 'emit')
component.rejectAll()
expect(rejectSpy).toHaveBeenCalledTimes(3)
expect(toastSpy).toHaveBeenCalled()
})
it('should return correct confidence class', () => {
expect(component.getConfidenceClass(0.9)).toBe('confidence-high')
expect(component.getConfidenceClass(0.7)).toBe('confidence-medium')
expect(component.getConfidenceClass(0.5)).toBe('confidence-low')
})
it('should return correct confidence label', () => {
expect(component.getConfidenceLabel(0.85)).toContain('85%')
expect(component.getConfidenceLabel(0.65)).toContain('65%')
expect(component.getConfidenceLabel(0.45)).toContain('45%')
})
it('should toggle collapse', () => {
expect(component.isCollapsed).toBe(false)
component.toggleCollapse()
expect(component.isCollapsed).toBe(true)
component.toggleCollapse()
expect(component.isCollapsed).toBe(false)
})
it('should respect disabled state', () => {
component.suggestions = mockSuggestions
component.disabled = true
component.ngOnChanges({
suggestions: {
currentValue: mockSuggestions,
previousValue: [],
firstChange: true,
isFirstChange: () => true,
},
})
const applySpy = jest.spyOn(component.apply, 'emit')
const suggestion = component.pendingSuggestions[0]
component.applySuggestion(suggestion)
expect(applySpy).not.toHaveBeenCalled()
})
it('should not render panel when there are no suggestions', () => {
component.suggestions = []
fixture.detectChanges()
expect(component.hasSuggestions).toBe(false)
})
it('should render panel when there are suggestions', () => {
component.suggestions = mockSuggestions
component.ngOnChanges({
suggestions: {
currentValue: mockSuggestions,
previousValue: [],
firstChange: true,
isFirstChange: () => true,
},
})
fixture.detectChanges()
expect(component.hasSuggestions).toBe(true)
})
})

View file

@ -0,0 +1,381 @@
import { CommonModule } from '@angular/common'
import {
trigger,
state,
style,
transition,
animate,
} from '@angular/animations'
import {
Component,
EventEmitter,
Input,
OnChanges,
Output,
SimpleChanges,
inject,
} from '@angular/core'
import { NgbCollapseModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import {
AISuggestion,
AISuggestionStatus,
AISuggestionType,
} from 'src/app/data/ai-suggestion'
import { Correspondent } from 'src/app/data/correspondent'
import { CustomField } from 'src/app/data/custom-field'
import { DocumentType } from 'src/app/data/document-type'
import { StoragePath } from 'src/app/data/storage-path'
import { Tag } from 'src/app/data/tag'
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { TagService } from 'src/app/services/rest/tag.service'
import { ToastService } from 'src/app/services/toast.service'
@Component({
selector: 'pngx-ai-suggestions-panel',
templateUrl: './ai-suggestions-panel.component.html',
styleUrls: ['./ai-suggestions-panel.component.scss'],
imports: [
CommonModule,
NgbCollapseModule,
NgxBootstrapIconsModule,
],
animations: [
trigger('slideIn', [
transition(':enter', [
style({ transform: 'translateY(-20px)', opacity: 0 }),
animate('300ms ease-out', style({ transform: 'translateY(0)', opacity: 1 })),
]),
]),
trigger('fadeInOut', [
transition(':enter', [
style({ opacity: 0, transform: 'scale(0.95)' }),
animate('200ms ease-out', style({ opacity: 1, transform: 'scale(1)' })),
]),
transition(':leave', [
animate('200ms ease-in', style({ opacity: 0, transform: 'scale(0.95)' })),
]),
]),
],
})
export class AiSuggestionsPanelComponent implements OnChanges {
private tagService = inject(TagService)
private correspondentService = inject(CorrespondentService)
private documentTypeService = inject(DocumentTypeService)
private storagePathService = inject(StoragePathService)
private customFieldsService = inject(CustomFieldsService)
private toastService = inject(ToastService)
@Input()
suggestions: AISuggestion[] = []
@Input()
disabled: boolean = false
@Output()
apply = new EventEmitter<AISuggestion>()
@Output()
reject = new EventEmitter<AISuggestion>()
public isCollapsed = false
public pendingSuggestions: AISuggestion[] = []
public groupedSuggestions: Map<AISuggestionType, AISuggestion[]> = new Map()
public appliedCount = 0
public rejectedCount = 0
private tags: Tag[] = []
private correspondents: Correspondent[] = []
private documentTypes: DocumentType[] = []
private storagePaths: StoragePath[] = []
private customFields: CustomField[] = []
public AISuggestionType = AISuggestionType
public AISuggestionStatus = AISuggestionStatus
ngOnChanges(changes: SimpleChanges): void {
if (changes['suggestions']) {
this.processSuggestions()
this.loadMetadata()
}
}
private processSuggestions(): void {
this.pendingSuggestions = this.suggestions.filter(
(s) => s.status === AISuggestionStatus.Pending
)
this.appliedCount = this.suggestions.filter(
(s) => s.status === AISuggestionStatus.Applied
).length
this.rejectedCount = this.suggestions.filter(
(s) => s.status === AISuggestionStatus.Rejected
).length
// Group suggestions by type
this.groupedSuggestions.clear()
this.pendingSuggestions.forEach((suggestion) => {
const group = this.groupedSuggestions.get(suggestion.type) || []
group.push(suggestion)
this.groupedSuggestions.set(suggestion.type, group)
})
}
private loadMetadata(): void {
// Load tags if needed
const tagSuggestions = this.pendingSuggestions.filter(
(s) => s.type === AISuggestionType.Tag
)
if (tagSuggestions.length > 0) {
this.tagService.listAll().subscribe((tags) => {
this.tags = tags.results
this.updateSuggestionLabels()
})
}
// Load correspondents if needed
const correspondentSuggestions = this.pendingSuggestions.filter(
(s) => s.type === AISuggestionType.Correspondent
)
if (correspondentSuggestions.length > 0) {
this.correspondentService.listAll().subscribe((correspondents) => {
this.correspondents = correspondents.results
this.updateSuggestionLabels()
})
}
// Load document types if needed
const documentTypeSuggestions = this.pendingSuggestions.filter(
(s) => s.type === AISuggestionType.DocumentType
)
if (documentTypeSuggestions.length > 0) {
this.documentTypeService.listAll().subscribe((documentTypes) => {
this.documentTypes = documentTypes.results
this.updateSuggestionLabels()
})
}
// Load storage paths if needed
const storagePathSuggestions = this.pendingSuggestions.filter(
(s) => s.type === AISuggestionType.StoragePath
)
if (storagePathSuggestions.length > 0) {
this.storagePathService.listAll().subscribe((storagePaths) => {
this.storagePaths = storagePaths.results
this.updateSuggestionLabels()
})
}
// Load custom fields if needed
const customFieldSuggestions = this.pendingSuggestions.filter(
(s) => s.type === AISuggestionType.CustomField
)
if (customFieldSuggestions.length > 0) {
this.customFieldsService.listAll().subscribe((customFields) => {
this.customFields = customFields.results
this.updateSuggestionLabels()
})
}
}
private updateSuggestionLabels(): void {
this.pendingSuggestions.forEach((suggestion) => {
if (!suggestion.label) {
suggestion.label = this.getLabel(suggestion)
}
})
}
public getLabel(suggestion: AISuggestion): string {
if (suggestion.label) {
return suggestion.label
}
switch (suggestion.type) {
case AISuggestionType.Tag:
const tag = this.tags.find((t) => t.id === suggestion.value)
return tag ? tag.name : `Tag #${suggestion.value}`
case AISuggestionType.Correspondent:
const correspondent = this.correspondents.find(
(c) => c.id === suggestion.value
)
return correspondent
? correspondent.name
: `Correspondent #${suggestion.value}`
case AISuggestionType.DocumentType:
const docType = this.documentTypes.find(
(dt) => dt.id === suggestion.value
)
return docType ? docType.name : `Document Type #${suggestion.value}`
case AISuggestionType.StoragePath:
const storagePath = this.storagePaths.find(
(sp) => sp.id === suggestion.value
)
return storagePath ? storagePath.name : `Storage Path #${suggestion.value}`
case AISuggestionType.CustomField:
return suggestion.field_name || 'Custom Field'
case AISuggestionType.Date:
return new Date(suggestion.value).toLocaleDateString()
case AISuggestionType.Title:
return suggestion.value
default:
return String(suggestion.value)
}
}
public getTypeLabel(type: AISuggestionType): string {
switch (type) {
case AISuggestionType.Tag:
return $localize`Tags`
case AISuggestionType.Correspondent:
return $localize`Correspondent`
case AISuggestionType.DocumentType:
return $localize`Document Type`
case AISuggestionType.StoragePath:
return $localize`Storage Path`
case AISuggestionType.CustomField:
return $localize`Custom Field`
case AISuggestionType.Date:
return $localize`Date`
case AISuggestionType.Title:
return $localize`Title`
default:
return String(type)
}
}
public getTypeIcon(type: AISuggestionType): string {
switch (type) {
case AISuggestionType.Tag:
return 'tag'
case AISuggestionType.Correspondent:
return 'person'
case AISuggestionType.DocumentType:
return 'file-earmark-text'
case AISuggestionType.StoragePath:
return 'folder'
case AISuggestionType.CustomField:
return 'input-cursor-text'
case AISuggestionType.Date:
return 'calendar'
case AISuggestionType.Title:
return 'pencil'
default:
return 'lightbulb'
}
}
public getConfidenceClass(confidence: number): string {
if (confidence >= 0.8) {
return 'confidence-high'
} else if (confidence >= 0.6) {
return 'confidence-medium'
} else {
return 'confidence-low'
}
}
public getConfidenceLabel(confidence: number): string {
const percentage = Math.round(confidence * 100)
if (confidence >= 0.8) {
return $localize`High (${percentage}%)`
} else if (confidence >= 0.6) {
return $localize`Medium (${percentage}%)`
} else {
return $localize`Low (${percentage}%)`
}
}
public getConfidenceIcon(confidence: number): string {
if (confidence >= 0.8) {
return 'check-circle-fill'
} else if (confidence >= 0.6) {
return 'exclamation-circle'
} else {
return 'question-circle'
}
}
public applySuggestion(suggestion: AISuggestion): void {
if (this.disabled) {
return
}
suggestion.status = AISuggestionStatus.Applied
this.apply.emit(suggestion)
this.processSuggestions()
this.toastService.showInfo(
$localize`Applied AI suggestion: ${this.getLabel(suggestion)}`
)
}
public rejectSuggestion(suggestion: AISuggestion): void {
if (this.disabled) {
return
}
suggestion.status = AISuggestionStatus.Rejected
this.reject.emit(suggestion)
this.processSuggestions()
this.toastService.showInfo(
$localize`Rejected AI suggestion: ${this.getLabel(suggestion)}`
)
}
public applyAll(): void {
if (this.disabled) {
return
}
const count = this.pendingSuggestions.length
this.pendingSuggestions.forEach((suggestion) => {
suggestion.status = AISuggestionStatus.Applied
this.apply.emit(suggestion)
})
this.processSuggestions()
this.toastService.showInfo(
$localize`Applied ${count} AI suggestions`
)
}
public rejectAll(): void {
if (this.disabled) {
return
}
const count = this.pendingSuggestions.length
this.pendingSuggestions.forEach((suggestion) => {
suggestion.status = AISuggestionStatus.Rejected
this.reject.emit(suggestion)
})
this.processSuggestions()
this.toastService.showInfo(
$localize`Rejected ${count} AI suggestions`
)
}
public toggleCollapse(): void {
this.isCollapsed = !this.isCollapsed
}
public get hasSuggestions(): boolean {
return this.pendingSuggestions.length > 0
}
public get suggestionTypes(): AISuggestionType[] {
return Array.from(this.groupedSuggestions.keys())
}
}

View file

@ -0,0 +1,32 @@
export enum AISuggestionType {
Tag = 'tag',
Correspondent = 'correspondent',
DocumentType = 'document_type',
StoragePath = 'storage_path',
CustomField = 'custom_field',
Date = 'date',
Title = 'title',
}
export enum AISuggestionStatus {
Pending = 'pending',
Applied = 'applied',
Rejected = 'rejected',
}
export interface AISuggestion {
id: string
type: AISuggestionType
value: any
confidence: number
status: AISuggestionStatus
label?: string
field_name?: string // For custom fields
created_at?: Date
}
export interface AIDocumentSuggestions {
document_id: number
suggestions: AISuggestion[]
generated_at: Date
}