671 lines
19 KiB
TypeScript
671 lines
19 KiB
TypeScript
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<string, any> = {
|
|
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<string, any> = {
|
|
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<string, TaxonomyOption[]> = {
|
|
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<string, any>): Record<string, any> {
|
|
const clean: Record<string, any> = {};
|
|
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;
|
|
}
|
|
}
|