all the project copy

This commit is contained in:
2026-07-16 12:27:06 +02:00
parent 926b7de75b
commit 568beef49a
631 changed files with 130346 additions and 0 deletions

View File

@@ -0,0 +1,670 @@
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;
}
}