import { CommonModule } from '@angular/common'; import { Component, OnInit } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { MatBadgeModule } from '@angular/material/badge'; import { MatButtonModule } from '@angular/material/button'; import { MatButtonToggleModule } from '@angular/material/button-toggle'; import { MatCardModule } from '@angular/material/card'; import { MatCheckboxModule } from '@angular/material/checkbox'; import { MatChipsModule } from '@angular/material/chips'; import { MatDividerModule } from '@angular/material/divider'; import { MatExpansionModule } from '@angular/material/expansion'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { MatListModule } from '@angular/material/list'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatSelectModule } from '@angular/material/select'; import { MatSidenavModule } from '@angular/material/sidenav'; import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar'; import { MatTableModule } from '@angular/material/table'; import { MatTabsModule } from '@angular/material/tabs'; import { MatToolbarModule } from '@angular/material/toolbar'; import { MatTooltipModule } from '@angular/material/tooltip'; import { forkJoin } from 'rxjs'; import { ApiService } from './api.service'; type ViewId = 'dashboard' | 'tickets' | 'search' | 'users'; type SearchMode = 'text' | 'ticket'; interface NavItem { id: ViewId; label: string; icon: string; } interface TaxonomyOption { id: number; label: string; } @Component({ selector: 'ticket-root', standalone: true, imports: [ CommonModule, FormsModule, MatBadgeModule, MatButtonModule, MatButtonToggleModule, MatCardModule, MatCheckboxModule, MatChipsModule, MatDividerModule, MatExpansionModule, MatFormFieldModule, MatIconModule, MatInputModule, MatListModule, MatProgressSpinnerModule, MatSelectModule, MatSidenavModule, MatSlideToggleModule, MatSnackBarModule, MatTableModule, MatTabsModule, MatToolbarModule, MatTooltipModule ], templateUrl: './app.component.html', styleUrls: ['./app.component.scss'] }) export class AppComponent implements OnInit { readonly navItems: NavItem[] = [ { id: 'dashboard', label: 'Dashboard', icon: 'dashboard' }, { id: 'tickets', label: 'Tickets', icon: 'confirmation_number' }, { id: 'search', label: 'AI search', icon: 'manage_search' }, { id: 'users', label: 'Users', icon: 'group' } ]; readonly ticketColumns = ['ticket', 'client', 'subject', 'product', 'status', 'opened', 'actions']; readonly userColumns = ['tid', 'name', 'status', 'filters', 'notifications', 'actions']; authChecked = true; user: any = null; loginForm = { username: 'admin', password: '' }; loginLoading = false; appError = ''; activeView: ViewId = 'dashboard'; dashboardData: any = null; dashboardLoading = false; dashboardError = ''; ticketFilters: Record = { q: '', date_from: '', date_to: '', product_id: '', competence_id: '', area_id: '', subarea_id: '', status_id: '', client_id: '' }; tickets: any[] = []; ticketLoading = false; ticketsError = ''; ticketOffset = 0; ticketHasMore = false; selectedTicket: any = null; selectedTicketLoading = false; selectedTicketError = ''; searchMode: SearchMode = 'text'; searchTextValue = ''; searchTicketId: number | null = null; searchFilters: Record = { product_id: '', competence_id: '', area_id: '', subarea_id: '', client_id: '', recent_months: '', min_confidence: '', require_code_match: false }; searchResults: any[] = []; searchLoading = false; searchError = ''; searchOffset = 0; searchHasMore = false; searchLimit = 8; users: any[] = []; usersLoading = false; usersError = ''; userQuery = ''; userStatus = ''; selectedUser: any = null; selectedUserFilters: any[] = []; selectedUserSettings: any = null; userDetailLoading = false; userDetailError = ''; filterDraft = { sort: 'C', sort_value: '' }; taxonomyLoading = false; taxonomyLoaded = false; taxonomyError = ''; taxonomy: Record = { products: [], competences: [], areas: [], subareas: [], statuses: [] }; constructor( private readonly api: ApiService, private readonly snack: MatSnackBar ) {} ngOnInit(): void { this.api.me().subscribe({ next: (response) => { this.user = response.user; if (this.user) { this.bootstrapAppData(); } }, error: () => { this.user = null; } }); } login(): void { if (!this.loginForm.username || !this.loginForm.password) { this.showMessage('Username and password are required'); return; } this.loginLoading = true; this.api.login(this.loginForm.username, this.loginForm.password).subscribe({ next: (response) => { this.loginLoading = false; this.user = response.user; this.loginForm.password = ''; this.bootstrapAppData(); }, error: (error) => { this.loginLoading = false; this.showError(error, 'Login failed'); } }); } logout(): void { this.api.logout().subscribe({ next: () => this.resetSession(), error: () => this.resetSession() }); } setView(view: ViewId): void { this.activeView = view; if (view === 'dashboard') { this.loadDashboard(); } if (view === 'tickets') { this.loadTaxonomy(); } if (view === 'tickets' && this.tickets.length === 0 && !this.ticketLoading) { this.loadTickets(true); } if (view === 'search') { this.loadTaxonomy(); } if (view === 'users') { this.loadTaxonomy(); } if (view === 'users' && this.users.length === 0 && !this.usersLoading) { this.loadUsers(); } } refreshActive(): void { if (this.activeView === 'dashboard') { this.loadDashboard(); } if (this.activeView === 'tickets') { this.loadTickets(true); } if (this.activeView === 'users') { this.loadUsers(); } if (this.activeView === 'search' && (this.searchTextValue.trim() || this.searchTicketId)) { this.runSearch(true); } } bootstrapAppData(): void { this.appError = ''; this.activeView = 'dashboard'; this.loadDashboard(); this.loadTaxonomy(true); this.loadTickets(true); this.loadUsers(); } loadDashboard(): void { if (!this.user) { return; } this.dashboardLoading = true; this.dashboardError = ''; this.api.dashboard().subscribe({ next: (response) => { this.dashboardData = response; this.dashboardLoading = false; }, error: (error) => { this.dashboardLoading = false; this.dashboardError = this.errorText(error, 'Dashboard could not be loaded'); this.showError(error, 'Dashboard could not be loaded'); } }); } loadTickets(reset: boolean): void { if (this.ticketLoading && !reset) { return; } if (reset) { this.ticketOffset = 0; this.tickets = []; } this.ticketLoading = true; this.ticketsError = ''; const params = this.cleanParams({ ...this.ticketFilters, limit: 50, offset: this.ticketOffset }); this.api.tickets(params).subscribe({ next: (response) => { const items = response.items || []; this.tickets = reset ? items : [...this.tickets, ...items]; this.ticketHasMore = !!response.has_more; this.ticketOffset = Number(response.offset || 0) + items.length; this.ticketLoading = false; }, error: (error) => { this.ticketLoading = false; this.ticketsError = this.errorText(error, 'Tickets could not be loaded'); this.showError(error, 'Tickets could not be loaded'); } }); } openTicket(ticketId: number): void { this.selectedTicketLoading = true; this.selectedTicket = null; this.selectedTicketError = ''; this.api.ticket(ticketId).subscribe({ next: (response) => { this.selectedTicket = response.ticket; this.selectedTicketLoading = false; }, error: (error) => { this.selectedTicketLoading = false; this.selectedTicketError = this.errorText(error, 'Ticket detail could not be loaded'); this.showError(error, 'Ticket detail could not be loaded'); } }); } searchFromTicket(ticketId: number): void { this.searchMode = 'ticket'; this.searchTicketId = ticketId; this.setView('search'); this.runSearch(true); } runSearch(reset: boolean): void { if (this.searchLoading) { return; } if (reset) { this.searchOffset = 0; this.searchResults = []; } this.searchError = ''; const params = this.cleanParams({ ...this.searchFilters, limit: this.searchLimit, offset: this.searchOffset }); if (!params['require_code_match']) { delete params['require_code_match']; } if (this.searchMode === 'text') { const query = this.searchTextValue.trim(); if (query.length < 2) { this.showMessage('Enter at least two characters to search'); return; } params['q'] = query; this.searchLoading = true; this.api.searchText(params).subscribe({ next: (response) => this.applySearchResponse(response, reset), error: (error) => { this.searchLoading = false; this.searchError = this.errorText(error, 'Search failed'); this.showError(error, 'Search failed'); } }); return; } const ticketId = Number(this.searchTicketId); if (!ticketId) { this.showMessage('Enter a ticket number'); return; } this.searchLoading = true; this.api.searchTicket(ticketId, params).subscribe({ next: (response) => this.applySearchResponse(response, reset), error: (error) => { this.searchLoading = false; this.searchError = this.errorText(error, 'Search failed'); this.showError(error, 'Search failed'); } }); } applySearchResponse(response: any, reset: boolean): void { const items = response.items || []; this.searchResults = reset ? items : [...this.searchResults, ...items]; this.searchHasMore = items.length === this.searchLimit; this.searchOffset = Number(response.offset || 0) + items.length; this.searchLoading = false; } markFeedback(result: any, feedback: 'useful' | 'not_useful' | 'favorite'): void { const payload: any = { suggested_ticket_id: Number(result.ticket_id), feedback }; if (this.searchMode === 'ticket' && this.searchTicketId) { payload.query_ticket_id = Number(this.searchTicketId); } if (feedback !== 'favorite' && !payload.query_ticket_id) { this.showMessage('Useful feedback needs a source ticket search'); return; } this.api.feedback(payload).subscribe({ next: () => { result.feedback_state = feedback; this.showMessage(feedback === 'favorite' ? 'Favorite saved' : 'Feedback saved'); }, error: (error) => this.showError(error, 'Feedback could not be saved') }); } loadUsers(): void { if (this.usersLoading) { return; } this.usersLoading = true; this.usersError = ''; this.api.users(this.cleanParams({ q: this.userQuery, status: this.userStatus })).subscribe({ next: (response) => { this.users = response.items || []; this.usersLoading = false; if (this.selectedUser) { const updated = this.users.find((item) => item.tid === this.selectedUser.tid); this.selectedUser = updated || this.selectedUser; } }, error: (error) => { this.usersLoading = false; this.usersError = this.errorText(error, 'Users could not be loaded'); this.showError(error, 'Users could not be loaded'); } }); } selectUser(user: any): void { this.selectedUser = user; this.userDetailLoading = true; this.userDetailError = ''; this.api.userFilters(user.tid).subscribe({ next: (response) => { this.selectedUserFilters = response.items || []; this.userDetailLoading = false; }, error: (error) => { this.userDetailLoading = false; this.userDetailError = this.errorText(error, 'Filters could not be loaded'); this.showError(error, 'Filters could not be loaded'); } }); this.api.userSettings(user.tid).subscribe({ next: (response) => { this.selectedUserSettings = response.settings; }, error: (error) => { this.userDetailError = this.errorText(error, 'Settings could not be loaded'); this.showError(error, 'Settings could not be loaded'); } }); } approveUser(user: any): void { this.api.updateUser(user.tid, { status: 'A' }).subscribe({ next: (response) => { Object.assign(user, response.user); this.showMessage('User approved'); this.loadUsers(); }, error: (error) => this.showError(error, 'User could not be approved') }); } toggleAdmin(user: any): void { this.api.updateUser(user.tid, { user_admin: !user.user_admin }).subscribe({ next: (response) => { Object.assign(user, response.user); this.showMessage(user.user_admin ? 'Admin enabled' : 'Admin disabled'); }, error: (error) => this.showError(error, 'Admin flag could not be changed') }); } addFilter(): void { if (!this.selectedUser || !this.filterDraft.sort_value) { this.showMessage('Choose a user and a filter value'); return; } this.api.addUserFilter(this.selectedUser.tid, this.filterDraft).subscribe({ next: () => { this.filterDraft.sort_value = ''; this.selectUser(this.selectedUser); this.showMessage('Filter saved'); }, error: (error) => this.showError(error, 'Filter could not be saved') }); } deleteFilter(filter: any): void { if (!this.selectedUser) { return; } this.api.deleteUserFilter(this.selectedUser.tid, filter.fid).subscribe({ next: () => { this.selectedUserFilters = this.selectedUserFilters.filter((item) => item.fid !== filter.fid); this.showMessage('Filter removed'); this.loadUsers(); }, error: (error) => this.showError(error, 'Filter could not be removed') }); } saveSettings(): void { if (!this.selectedUser || !this.selectedUserSettings) { return; } const settings = this.selectedUserSettings; const patch = { notifications_enabled: !!settings.notifications_enabled, quiet_start_hour: this.emptyToNull(settings.quiet_start_hour), quiet_end_hour: this.emptyToNull(settings.quiet_end_hour), digest_minutes: Number(settings.digest_minutes || 0), high_gravity_only: !!settings.high_gravity_only }; this.api.updateUserSettings(this.selectedUser.tid, patch).subscribe({ next: (response) => { this.selectedUserSettings = response.settings; this.showMessage('Notification settings saved'); this.loadUsers(); }, error: (error) => this.showError(error, 'Settings could not be saved') }); } clearQuietHours(): void { if (!this.selectedUserSettings) { return; } this.selectedUserSettings.quiet_start_hour = null; this.selectedUserSettings.quiet_end_hour = null; } onFilterSortChange(): void { this.filterDraft.sort_value = ''; } filterOptions(sort: string): TaxonomyOption[] { if (sort === 'P') { return this.taxonomy['products']; } if (sort === 'C') { return this.taxonomy['competences']; } if (sort === 'A') { return this.taxonomy['areas']; } if (sort === 'S') { return this.taxonomy['subareas']; } return []; } loadTaxonomy(force = false): void { if (this.taxonomyLoading || (this.taxonomyLoaded && !force)) { return; } this.taxonomyLoading = true; this.taxonomyError = ''; forkJoin({ products: this.api.taxonomy('products', { limit: 400 }), competences: this.api.taxonomy('competences', { limit: 400 }), areas: this.api.taxonomy('areas', { limit: 400 }), subareas: this.api.taxonomy('subareas', { limit: 600 }), statuses: this.api.taxonomy('statuses', { limit: 400 }) }).subscribe({ next: (response: any) => { this.taxonomy.products = response.products?.items || []; this.taxonomy.competences = response.competences?.items || []; this.taxonomy.areas = response.areas?.items || []; this.taxonomy.subareas = response.subareas?.items || []; this.taxonomy.statuses = response.statuses?.items || []; this.taxonomyLoaded = true; this.taxonomyLoading = false; }, error: (error) => { this.taxonomyLoading = false; this.taxonomyLoaded = false; this.taxonomyError = this.errorText(error, 'Filter lists could not be loaded'); this.showError(error, 'Filter lists could not be loaded'); } }); } resetTicketFilters(): void { Object.keys(this.ticketFilters).forEach((key) => { this.ticketFilters[key] = ''; }); this.loadTickets(true); } resetSearchFilters(): void { Object.keys(this.searchFilters).forEach((key) => { this.searchFilters[key] = key === 'require_code_match' ? false : ''; }); } textInputValue(event: Event): string { return (event.target as HTMLInputElement | null)?.value || ''; } numberInputValue(event: Event): number | null { const value = (event.target as HTMLInputElement | null)?.value || ''; return value ? Number(value) : null; } shortText(value: unknown, limit = 150): string { const text = String(value || '').trim(); if (text.length <= limit) { return text; } return `${text.slice(0, limit).trim()}...`; } trackByTicket(_: number, item: any): number { return Number(item.ticket_id); } trackByUser(_: number, item: any): number { return Number(item.tid); } trackByFilter(_: number, item: any): number { return Number(item.fid); } private cleanParams(params: Record): Record { const clean: Record = {}; Object.entries(params).forEach(([key, value]) => { if (value === null || value === undefined || value === '') { return; } clean[key] = value; }); return clean; } private emptyToNull(value: any): number | null { if (value === '' || value === null || value === undefined) { return null; } return Number(value); } private showMessage(message: string): void { this.snack.open(message, 'Close', { duration: 3500 }); } private showError(error: any, fallback: string): void { const detail = this.errorText(error, fallback); this.appError = detail; this.snack.open(String(detail), 'Close', { duration: 6000 }); } private errorText(error: any, fallback: string): string { return String(error?.error?.detail || error?.message || fallback); } private resetSession(): void { this.user = null; this.dashboardData = null; this.tickets = []; this.searchResults = []; this.users = []; this.selectedUser = null; this.selectedTicket = null; } }