all the project copy
This commit is contained in:
1
web/frontend/.angular/cache/21.2.19/ticket-web/.tsbuildinfo
vendored
Normal file
1
web/frontend/.angular/cache/21.2.19/ticket-web/.tsbuildinfo
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
web/frontend/.angular/cache/21.2.19/ticket-web/angular-compiler.db
vendored
Normal file
BIN
web/frontend/.angular/cache/21.2.19/ticket-web/angular-compiler.db
vendored
Normal file
Binary file not shown.
BIN
web/frontend/.angular/cache/21.2.19/ticket-web/angular-compiler.db-lock
vendored
Normal file
BIN
web/frontend/.angular/cache/21.2.19/ticket-web/angular-compiler.db-lock
vendored
Normal file
Binary file not shown.
68
web/frontend/angular.json
Normal file
68
web/frontend/angular.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"ticket-web": {
|
||||
"projectType": "application",
|
||||
"schematics": {},
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "ticket",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:application",
|
||||
"options": {
|
||||
"outputPath": "dist/ticket-web",
|
||||
"index": "src/index.html",
|
||||
"browser": "src/main.ts",
|
||||
"polyfills": ["zone.js"],
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": ["src/assets"],
|
||||
"styles": [
|
||||
"node_modules/@angular/material/prebuilt-themes/azure-blue.css",
|
||||
"src/styles.scss"
|
||||
],
|
||||
"scripts": []
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "1.2MB",
|
||||
"maximumError": "2MB"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "8kB",
|
||||
"maximumError": "16kB"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
},
|
||||
"development": {
|
||||
"optimization": false,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "ticket-web:build:production"
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "ticket-web:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
14368
web/frontend/package-lock.json
generated
Normal file
14368
web/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
web/frontend/package.json
Normal file
31
web/frontend/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "ticket-web-console",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "ng serve --host 0.0.0.0 --port 4200",
|
||||
"build": "ng build",
|
||||
"lint": "ng lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@angular/animations": "^21.2.18",
|
||||
"@angular/cdk": "^21.2.14",
|
||||
"@angular/common": "^21.2.18",
|
||||
"@angular/compiler": "^21.2.18",
|
||||
"@angular/core": "^21.2.18",
|
||||
"@angular/forms": "^21.2.18",
|
||||
"@angular/material": "^21.2.14",
|
||||
"@angular/platform-browser": "^21.2.18",
|
||||
"@angular/platform-browser-dynamic": "^21.2.18",
|
||||
"@angular/router": "^21.2.18",
|
||||
"rxjs": "~7.8.1",
|
||||
"tslib": "^2.6.3",
|
||||
"zone.js": "~0.16.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^21.2.19",
|
||||
"@angular/cli": "^21.2.19",
|
||||
"@angular/compiler-cli": "^21.2.18",
|
||||
"typescript": "~5.9.3"
|
||||
}
|
||||
}
|
||||
103
web/frontend/src/app/api.service.ts
Normal file
103
web/frontend/src/app/api.service.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Injectable, NgZone } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
type Params = Record<string, string | number | boolean | null | undefined>;
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ApiService {
|
||||
constructor(
|
||||
private readonly http: HttpClient,
|
||||
private readonly zone: NgZone
|
||||
) {}
|
||||
|
||||
login(username: string, password: string): Observable<any> {
|
||||
return this.inAngularZone(this.http.post('/api/auth/login', { username, password }, { withCredentials: true }));
|
||||
}
|
||||
|
||||
me(): Observable<any> {
|
||||
return this.inAngularZone(this.http.get('/api/auth/me', { withCredentials: true }));
|
||||
}
|
||||
|
||||
logout(): Observable<any> {
|
||||
return this.inAngularZone(this.http.post('/api/auth/logout', {}, { withCredentials: true }));
|
||||
}
|
||||
|
||||
dashboard(): Observable<any> {
|
||||
return this.inAngularZone(this.http.get('/api/dashboard'));
|
||||
}
|
||||
|
||||
tickets(params: Params): Observable<any> {
|
||||
return this.inAngularZone(this.http.get('/api/tickets', { params: this.toHttpParams(params) }));
|
||||
}
|
||||
|
||||
ticket(ticketId: number): Observable<any> {
|
||||
return this.inAngularZone(this.http.get(`/api/tickets/${ticketId}`));
|
||||
}
|
||||
|
||||
searchText(params: Params): Observable<any> {
|
||||
return this.inAngularZone(this.http.get('/api/search/text', { params: this.toHttpParams(params) }));
|
||||
}
|
||||
|
||||
searchTicket(ticketId: number, params: Params): Observable<any> {
|
||||
return this.inAngularZone(this.http.get(`/api/search/ticket/${ticketId}`, { params: this.toHttpParams(params) }));
|
||||
}
|
||||
|
||||
feedback(payload: any): Observable<any> {
|
||||
return this.inAngularZone(this.http.post('/api/feedback', payload));
|
||||
}
|
||||
|
||||
users(params: Params): Observable<any> {
|
||||
return this.inAngularZone(this.http.get('/api/users', { params: this.toHttpParams(params) }));
|
||||
}
|
||||
|
||||
updateUser(tid: number, patch: any): Observable<any> {
|
||||
return this.inAngularZone(this.http.patch(`/api/users/${tid}`, patch));
|
||||
}
|
||||
|
||||
userFilters(tid: number): Observable<any> {
|
||||
return this.inAngularZone(this.http.get(`/api/users/${tid}/filters`));
|
||||
}
|
||||
|
||||
addUserFilter(tid: number, payload: any): Observable<any> {
|
||||
return this.inAngularZone(this.http.post(`/api/users/${tid}/filters`, payload));
|
||||
}
|
||||
|
||||
deleteUserFilter(tid: number, fid: number): Observable<any> {
|
||||
return this.inAngularZone(this.http.delete(`/api/users/${tid}/filters/${fid}`));
|
||||
}
|
||||
|
||||
userSettings(tid: number): Observable<any> {
|
||||
return this.inAngularZone(this.http.get(`/api/users/${tid}/settings`));
|
||||
}
|
||||
|
||||
updateUserSettings(tid: number, patch: any): Observable<any> {
|
||||
return this.inAngularZone(this.http.patch(`/api/users/${tid}/settings`, patch));
|
||||
}
|
||||
|
||||
taxonomy(kind: string, params: Params = {}): Observable<any> {
|
||||
return this.inAngularZone(this.http.get(`/api/taxonomy/${kind}`, { params: this.toHttpParams(params) }));
|
||||
}
|
||||
|
||||
private inAngularZone<T>(source: Observable<T>): Observable<T> {
|
||||
return new Observable<T>((observer) => {
|
||||
const subscription = source.subscribe({
|
||||
next: (value) => this.zone.run(() => observer.next(value)),
|
||||
error: (error) => this.zone.run(() => observer.error(error)),
|
||||
complete: () => this.zone.run(() => observer.complete())
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
});
|
||||
}
|
||||
|
||||
private toHttpParams(params: Params): HttpParams {
|
||||
let httpParams = new HttpParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return;
|
||||
}
|
||||
httpParams = httpParams.set(key, String(value));
|
||||
});
|
||||
return httpParams;
|
||||
}
|
||||
}
|
||||
645
web/frontend/src/app/app.component.html
Normal file
645
web/frontend/src/app/app.component.html
Normal file
@@ -0,0 +1,645 @@
|
||||
<div class="loading-screen" *ngIf="!authChecked">
|
||||
<mat-spinner diameter="40"></mat-spinner>
|
||||
</div>
|
||||
|
||||
<main class="login-shell" *ngIf="authChecked && !user">
|
||||
<section class="login-panel">
|
||||
<div class="brand-mark">
|
||||
<mat-icon>confirmation_number</mat-icon>
|
||||
</div>
|
||||
<h1>Ticket Web Console</h1>
|
||||
<p>Operational access for Telegram users, filters, notifications, and ticket search.</p>
|
||||
|
||||
<form (ngSubmit)="login()" class="login-form">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Username</mat-label>
|
||||
<input
|
||||
matInput
|
||||
name="username"
|
||||
autocomplete="username"
|
||||
[(ngModel)]="loginForm.username"
|
||||
(input)="loginForm.username = textInputValue($event)">
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Password</mat-label>
|
||||
<input
|
||||
matInput
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
[(ngModel)]="loginForm.password"
|
||||
(input)="loginForm.password = textInputValue($event)">
|
||||
</mat-form-field>
|
||||
|
||||
<button mat-flat-button color="primary" type="submit" [disabled]="loginLoading">
|
||||
<mat-icon>login</mat-icon>
|
||||
Sign in
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<mat-sidenav-container class="app-shell" *ngIf="authChecked && user">
|
||||
<mat-sidenav mode="side" opened class="sidenav">
|
||||
<div class="side-title">
|
||||
<mat-icon>confirmation_number</mat-icon>
|
||||
<span>Ticket</span>
|
||||
</div>
|
||||
<mat-nav-list>
|
||||
<button
|
||||
mat-button
|
||||
class="nav-button"
|
||||
*ngFor="let item of navItems"
|
||||
[class.active]="activeView === item.id"
|
||||
(click)="setView(item.id)">
|
||||
<mat-icon>{{ item.icon }}</mat-icon>
|
||||
<span>{{ item.label }}</span>
|
||||
</button>
|
||||
</mat-nav-list>
|
||||
</mat-sidenav>
|
||||
|
||||
<mat-sidenav-content>
|
||||
<mat-toolbar class="topbar">
|
||||
<div>
|
||||
<div class="eyebrow">San Marco Ticketing</div>
|
||||
<h2>{{ activeView }}</h2>
|
||||
</div>
|
||||
<span class="spacer"></span>
|
||||
<button mat-icon-button matTooltip="Refresh" (click)="refreshActive()">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
</button>
|
||||
<div class="user-pill">
|
||||
<mat-icon>admin_panel_settings</mat-icon>
|
||||
<span>{{ user.display_name || user.username }}</span>
|
||||
</div>
|
||||
<button mat-stroked-button (click)="logout()">
|
||||
<mat-icon>logout</mat-icon>
|
||||
Logout
|
||||
</button>
|
||||
</mat-toolbar>
|
||||
|
||||
<section class="content">
|
||||
<div class="alert-banner" *ngIf="appError">
|
||||
<mat-icon>error</mat-icon>
|
||||
<span>{{ appError }}</span>
|
||||
<button mat-icon-button matTooltip="Dismiss" (click)="appError = ''">
|
||||
<mat-icon>close</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ng-container [ngSwitch]="activeView">
|
||||
<section *ngSwitchCase="'dashboard'" class="view-stack">
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<h3>Overview</h3>
|
||||
<p>Bot activity, indexed knowledge, and recent ticket movement.</p>
|
||||
</div>
|
||||
<mat-spinner *ngIf="dashboardLoading" diameter="28"></mat-spinner>
|
||||
</div>
|
||||
|
||||
<div class="inline-error" *ngIf="dashboardError">
|
||||
<mat-icon>warning</mat-icon>
|
||||
<span>{{ dashboardError }}</span>
|
||||
</div>
|
||||
|
||||
<div class="kpi-grid" *ngIf="dashboardData">
|
||||
<mat-card class="kpi-card">
|
||||
<mat-icon>inventory_2</mat-icon>
|
||||
<span>Indexed tickets</span>
|
||||
<strong>{{ dashboardData.ai?.indexed_count || 0 }}</strong>
|
||||
</mat-card>
|
||||
<mat-card class="kpi-card">
|
||||
<mat-icon>manage_search</mat-icon>
|
||||
<span>AI searches</span>
|
||||
<strong>{{ dashboardData.ai?.search_count || 0 }}</strong>
|
||||
</mat-card>
|
||||
<mat-card class="kpi-card warn" [class.hot]="(dashboardData.bot?.pending_users || 0) > 0">
|
||||
<mat-icon>person_add</mat-icon>
|
||||
<span>Pending users</span>
|
||||
<strong>{{ dashboardData.bot?.pending_users || 0 }}</strong>
|
||||
</mat-card>
|
||||
<mat-card class="kpi-card">
|
||||
<mat-icon>filter_alt</mat-icon>
|
||||
<span>Saved filters</span>
|
||||
<strong>{{ dashboardData.bot?.filters || 0 }}</strong>
|
||||
</mat-card>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-grid" *ngIf="dashboardData">
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h4>Recent tickets</h4>
|
||||
<span *ngIf="!dashboardData.mysql?.ok" class="status-bad">MySQL unavailable</span>
|
||||
</div>
|
||||
<div class="compact-list" *ngIf="dashboardData.recent_tickets?.length; else noRecentTickets">
|
||||
<button class="compact-row" *ngFor="let ticket of dashboardData.recent_tickets" (click)="openTicket(ticket.ticket_id); setView('tickets')">
|
||||
<span class="ticket-number">#{{ ticket.ticket_id }}</span>
|
||||
<span class="compact-main">{{ shortText(ticket.subject, 90) }}</span>
|
||||
<span class="compact-meta">{{ ticket.product || '-' }} · {{ ticket.opened_at | date:'short' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<ng-template #noRecentTickets>
|
||||
<p class="empty-text">No recent tickets returned by MySQL.</p>
|
||||
</ng-template>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h4>Recent feedback</h4>
|
||||
</div>
|
||||
<div class="compact-list" *ngIf="dashboardData.recent_feedback?.length; else noFeedback">
|
||||
<button class="compact-row" *ngFor="let feedback of dashboardData.recent_feedback" (click)="openTicket(feedback.suggested_ticket_id); setView('tickets')">
|
||||
<span class="ticket-number">{{ feedback.feedback }}</span>
|
||||
<span class="compact-main">#{{ feedback.query_ticket_id }} → #{{ feedback.suggested_ticket_id }}</span>
|
||||
<span class="compact-meta">{{ feedback.product || '-' }} · {{ feedback.created_at | date:'short' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<ng-template #noFeedback>
|
||||
<p class="empty-text">No feedback recorded yet.</p>
|
||||
</ng-template>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section *ngSwitchCase="'tickets'" class="view-stack">
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<h3>Tickets</h3>
|
||||
<p>Read-only browsing from MySQL. Default range is the last 30 days.</p>
|
||||
</div>
|
||||
<button mat-flat-button color="primary" (click)="loadTickets(true)" [disabled]="ticketLoading">
|
||||
<mat-icon>search</mat-icon>
|
||||
Load
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section class="filter-bar">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Ticket, client, subject</mat-label>
|
||||
<input matInput [(ngModel)]="ticketFilters.q" (keyup.enter)="loadTickets(true)">
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>From</mat-label>
|
||||
<input matInput type="date" [(ngModel)]="ticketFilters.date_from">
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>To</mat-label>
|
||||
<input matInput type="date" [(ngModel)]="ticketFilters.date_to">
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Product</mat-label>
|
||||
<mat-select [(ngModel)]="ticketFilters.product_id">
|
||||
<mat-option value="">Any</mat-option>
|
||||
<mat-option *ngFor="let option of taxonomy.products" [value]="option.id">{{ option.label }}</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Competence</mat-label>
|
||||
<mat-select [(ngModel)]="ticketFilters.competence_id">
|
||||
<mat-option value="">Any</mat-option>
|
||||
<mat-option *ngFor="let option of taxonomy.competences" [value]="option.id">{{ option.label }}</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Status</mat-label>
|
||||
<mat-select [(ngModel)]="ticketFilters.status_id">
|
||||
<mat-option value="">Any</mat-option>
|
||||
<mat-option *ngFor="let option of taxonomy.statuses" [value]="option.id">{{ option.label }}</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<button mat-stroked-button (click)="resetTicketFilters()">
|
||||
<mat-icon>backspace</mat-icon>
|
||||
Reset
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div class="inline-status" *ngIf="taxonomyLoading || taxonomyError">
|
||||
<mat-spinner *ngIf="taxonomyLoading" diameter="20"></mat-spinner>
|
||||
<span *ngIf="taxonomyLoading">Loading filter lists...</span>
|
||||
<mat-icon *ngIf="taxonomyError">warning</mat-icon>
|
||||
<span *ngIf="taxonomyError">{{ taxonomyError }}</span>
|
||||
</div>
|
||||
|
||||
<div class="inline-error" *ngIf="ticketsError">
|
||||
<mat-icon>warning</mat-icon>
|
||||
<span>{{ ticketsError }}</span>
|
||||
</div>
|
||||
|
||||
<section class="table-panel">
|
||||
<table mat-table [dataSource]="tickets" [trackBy]="trackByTicket" class="data-table">
|
||||
<ng-container matColumnDef="ticket">
|
||||
<th mat-header-cell *matHeaderCellDef>Ticket</th>
|
||||
<td mat-cell *matCellDef="let row"><strong>#{{ row.ticket_id }}</strong></td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="client">
|
||||
<th mat-header-cell *matHeaderCellDef>Client</th>
|
||||
<td mat-cell *matCellDef="let row">{{ shortText(row.client, 42) }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="subject">
|
||||
<th mat-header-cell *matHeaderCellDef>Subject</th>
|
||||
<td mat-cell *matCellDef="let row">{{ shortText(row.subject, 110) }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="product">
|
||||
<th mat-header-cell *matHeaderCellDef>Product</th>
|
||||
<td mat-cell *matCellDef="let row">{{ row.product || '-' }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="status">
|
||||
<th mat-header-cell *matHeaderCellDef>Status</th>
|
||||
<td mat-cell *matCellDef="let row">{{ row.status || '-' }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="opened">
|
||||
<th mat-header-cell *matHeaderCellDef>Opened</th>
|
||||
<td mat-cell *matCellDef="let row">{{ row.opened_at | date:'short' }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef></th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<button mat-icon-button matTooltip="Open detail" (click)="openTicket(row.ticket_id)">
|
||||
<mat-icon>open_in_new</mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button matTooltip="Find similar" (click)="searchFromTicket(row.ticket_id)">
|
||||
<mat-icon>hub</mat-icon>
|
||||
</button>
|
||||
</td>
|
||||
</ng-container>
|
||||
<tr mat-header-row *matHeaderRowDef="ticketColumns"></tr>
|
||||
<tr mat-row *matRowDef="let row; columns: ticketColumns"></tr>
|
||||
</table>
|
||||
<div class="table-footer">
|
||||
<mat-spinner *ngIf="ticketLoading" diameter="24"></mat-spinner>
|
||||
<span *ngIf="!ticketLoading">{{ tickets.length }} tickets loaded</span>
|
||||
<button mat-stroked-button *ngIf="ticketHasMore" (click)="loadTickets(false)" [disabled]="ticketLoading">
|
||||
<mat-icon>expand_more</mat-icon>
|
||||
More
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="detail-panel" *ngIf="selectedTicket || selectedTicketLoading">
|
||||
<div class="panel-header">
|
||||
<h4>Ticket detail</h4>
|
||||
<mat-spinner *ngIf="selectedTicketLoading" diameter="24"></mat-spinner>
|
||||
</div>
|
||||
<div class="inline-error" *ngIf="selectedTicketError">
|
||||
<mat-icon>warning</mat-icon>
|
||||
<span>{{ selectedTicketError }}</span>
|
||||
</div>
|
||||
<ng-container *ngIf="selectedTicket">
|
||||
<div class="detail-title">
|
||||
<strong>#{{ selectedTicket.ticket_id }}</strong>
|
||||
<a [href]="selectedTicket.source_link" target="_blank" rel="noreferrer">Open source</a>
|
||||
</div>
|
||||
<div class="detail-meta">
|
||||
<span>{{ selectedTicket.client || '-' }}</span>
|
||||
<span>{{ selectedTicket.product || '-' }}</span>
|
||||
<span>{{ selectedTicket.competence || '-' }}</span>
|
||||
<span>{{ selectedTicket.status || '-' }}</span>
|
||||
</div>
|
||||
<h5>{{ selectedTicket.subject || 'No subject' }}</h5>
|
||||
<div class="text-columns">
|
||||
<article>
|
||||
<h6>Problem</h6>
|
||||
<p>{{ selectedTicket.problem || 'Not available' }}</p>
|
||||
</article>
|
||||
<article>
|
||||
<h6>Solution</h6>
|
||||
<p>{{ selectedTicket.solution || 'Not available' }}</p>
|
||||
</article>
|
||||
</div>
|
||||
</ng-container>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section *ngSwitchCase="'search'" class="view-stack">
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<h3>AI search</h3>
|
||||
<p>Search the Postgres AI index, then open read-only ticket detail from MySQL.</p>
|
||||
</div>
|
||||
<button mat-stroked-button (click)="resetSearchFilters()">
|
||||
<mat-icon>filter_alt_off</mat-icon>
|
||||
Clear filters
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form class="search-panel" (ngSubmit)="runSearch(true)">
|
||||
<mat-button-toggle-group [(ngModel)]="searchMode" name="searchMode" aria-label="Search mode">
|
||||
<mat-button-toggle value="text">
|
||||
<mat-icon>subject</mat-icon>
|
||||
Text
|
||||
</mat-button-toggle>
|
||||
<mat-button-toggle value="ticket">
|
||||
<mat-icon>confirmation_number</mat-icon>
|
||||
Ticket
|
||||
</mat-button-toggle>
|
||||
</mat-button-toggle-group>
|
||||
|
||||
<mat-form-field appearance="outline" class="search-input" *ngIf="searchMode === 'text'">
|
||||
<mat-label>Describe the problem</mat-label>
|
||||
<input
|
||||
matInput
|
||||
name="searchTextValue"
|
||||
[(ngModel)]="searchTextValue"
|
||||
(input)="searchTextValue = textInputValue($event)">
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline" class="search-input" *ngIf="searchMode === 'ticket'">
|
||||
<mat-label>Source ticket number</mat-label>
|
||||
<input
|
||||
matInput
|
||||
name="searchTicketId"
|
||||
type="number"
|
||||
[(ngModel)]="searchTicketId"
|
||||
(input)="searchTicketId = numberInputValue($event)">
|
||||
</mat-form-field>
|
||||
|
||||
<button mat-flat-button color="primary" type="submit" [disabled]="searchLoading">
|
||||
<mat-icon>manage_search</mat-icon>
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<section class="filter-bar compact">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Product</mat-label>
|
||||
<mat-select [(ngModel)]="searchFilters.product_id">
|
||||
<mat-option value="">Any</mat-option>
|
||||
<mat-option *ngFor="let option of taxonomy.products" [value]="option.id">{{ option.label }}</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Area</mat-label>
|
||||
<mat-select [(ngModel)]="searchFilters.area_id">
|
||||
<mat-option value="">Any</mat-option>
|
||||
<mat-option *ngFor="let option of taxonomy.areas" [value]="option.id">{{ option.label }}</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Competence</mat-label>
|
||||
<mat-select [(ngModel)]="searchFilters.competence_id">
|
||||
<mat-option value="">Any</mat-option>
|
||||
<mat-option *ngFor="let option of taxonomy.competences" [value]="option.id">{{ option.label }}</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Recent months</mat-label>
|
||||
<input matInput type="number" min="1" max="120" [(ngModel)]="searchFilters.recent_months">
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Confidence</mat-label>
|
||||
<mat-select [(ngModel)]="searchFilters.min_confidence">
|
||||
<mat-option value="">Any</mat-option>
|
||||
<mat-option value="media">Media</mat-option>
|
||||
<mat-option value="alta">Alta</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-checkbox [(ngModel)]="searchFilters.require_code_match">Same code</mat-checkbox>
|
||||
</section>
|
||||
|
||||
<div class="inline-status" *ngIf="taxonomyLoading || taxonomyError">
|
||||
<mat-spinner *ngIf="taxonomyLoading" diameter="20"></mat-spinner>
|
||||
<span *ngIf="taxonomyLoading">Loading filter lists...</span>
|
||||
<mat-icon *ngIf="taxonomyError">warning</mat-icon>
|
||||
<span *ngIf="taxonomyError">{{ taxonomyError }}</span>
|
||||
</div>
|
||||
|
||||
<div class="inline-error" *ngIf="searchError">
|
||||
<mat-icon>warning</mat-icon>
|
||||
<span>{{ searchError }}</span>
|
||||
</div>
|
||||
|
||||
<section class="results-stack">
|
||||
<mat-spinner *ngIf="searchLoading" diameter="30"></mat-spinner>
|
||||
<article class="result-row" *ngFor="let result of searchResults">
|
||||
<div class="result-head">
|
||||
<div>
|
||||
<strong>#{{ result.ticket_id }}</strong>
|
||||
<span>{{ result.confidence }} · {{ result.total_score | number:'1.3-3' }}</span>
|
||||
</div>
|
||||
<div class="result-actions">
|
||||
<button mat-icon-button matTooltip="Open detail" (click)="openTicket(result.ticket_id); setView('tickets')">
|
||||
<mat-icon>open_in_new</mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button matTooltip="Favorite" (click)="markFeedback(result, 'favorite')">
|
||||
<mat-icon>{{ result.feedback_state === 'favorite' ? 'star' : 'star_border' }}</mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button matTooltip="Useful" *ngIf="searchMode === 'ticket'" (click)="markFeedback(result, 'useful')">
|
||||
<mat-icon>thumb_up</mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button matTooltip="Not useful" *ngIf="searchMode === 'ticket'" (click)="markFeedback(result, 'not_useful')">
|
||||
<mat-icon>thumb_down</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<h4>{{ result.subject_text || 'No subject' }}</h4>
|
||||
<p>{{ result.solution_preview || result.problem_text || 'No preview available' }}</p>
|
||||
<mat-chip-set>
|
||||
<mat-chip *ngFor="let reason of result.reasons">{{ reason }}</mat-chip>
|
||||
<mat-chip>{{ result.product || '-' }}</mat-chip>
|
||||
<mat-chip>{{ result.area || '-' }}</mat-chip>
|
||||
</mat-chip-set>
|
||||
</article>
|
||||
<div class="empty-state" *ngIf="!searchLoading && searchResults.length === 0">
|
||||
<mat-icon>travel_explore</mat-icon>
|
||||
<span>No search results loaded.</span>
|
||||
</div>
|
||||
<button mat-stroked-button *ngIf="searchHasMore" (click)="runSearch(false)" [disabled]="searchLoading">
|
||||
<mat-icon>expand_more</mat-icon>
|
||||
More results
|
||||
</button>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section *ngSwitchCase="'users'" class="view-stack">
|
||||
<div class="section-header">
|
||||
<div>
|
||||
<h3>Telegram users</h3>
|
||||
<p>Approve users, manage saved filters, and adjust notification preferences.</p>
|
||||
</div>
|
||||
<button mat-flat-button color="primary" (click)="loadUsers()" [disabled]="usersLoading">
|
||||
<mat-icon>refresh</mat-icon>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section class="filter-bar user-filter">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Search user</mat-label>
|
||||
<input matInput [(ngModel)]="userQuery" (keyup.enter)="loadUsers()">
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Status</mat-label>
|
||||
<mat-select [(ngModel)]="userStatus" (selectionChange)="loadUsers()">
|
||||
<mat-option value="">All</mat-option>
|
||||
<mat-option value="P">Pending</mat-option>
|
||||
<mat-option value="A">Approved</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</section>
|
||||
|
||||
<div class="inline-status" *ngIf="taxonomyLoading || taxonomyError">
|
||||
<mat-spinner *ngIf="taxonomyLoading" diameter="20"></mat-spinner>
|
||||
<span *ngIf="taxonomyLoading">Loading filter lists...</span>
|
||||
<mat-icon *ngIf="taxonomyError">warning</mat-icon>
|
||||
<span *ngIf="taxonomyError">{{ taxonomyError }}</span>
|
||||
</div>
|
||||
|
||||
<div class="inline-error" *ngIf="usersError">
|
||||
<mat-icon>warning</mat-icon>
|
||||
<span>{{ usersError }}</span>
|
||||
</div>
|
||||
|
||||
<div class="users-layout">
|
||||
<section class="table-panel">
|
||||
<table mat-table [dataSource]="users" [trackBy]="trackByUser" class="data-table user-table">
|
||||
<ng-container matColumnDef="tid">
|
||||
<th mat-header-cell *matHeaderCellDef>Telegram ID</th>
|
||||
<td mat-cell *matCellDef="let row">{{ row.tid }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="name">
|
||||
<th mat-header-cell *matHeaderCellDef>Name</th>
|
||||
<td mat-cell *matCellDef="let row">{{ row.username || '-' }} {{ row.surname || '' }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="status">
|
||||
<th mat-header-cell *matHeaderCellDef>Status</th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<span class="status-pill" [class.pending]="row.status === 'P'">{{ row.status === 'A' ? 'Approved' : 'Pending' }}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="filters">
|
||||
<th mat-header-cell *matHeaderCellDef>Filters</th>
|
||||
<td mat-cell *matCellDef="let row">{{ row.filter_count }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="notifications">
|
||||
<th mat-header-cell *matHeaderCellDef>Notifications</th>
|
||||
<td mat-cell *matCellDef="let row">{{ row.notifications_enabled ? 'On' : 'Paused' }}</td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="actions">
|
||||
<th mat-header-cell *matHeaderCellDef></th>
|
||||
<td mat-cell *matCellDef="let row">
|
||||
<button mat-icon-button matTooltip="Approve" *ngIf="row.status === 'P'" (click)="approveUser(row); $event.stopPropagation()">
|
||||
<mat-icon>check_circle</mat-icon>
|
||||
</button>
|
||||
<button mat-icon-button matTooltip="Toggle bot admin" (click)="toggleAdmin(row); $event.stopPropagation()">
|
||||
<mat-icon>{{ row.user_admin ? 'shield' : 'shield_person' }}</mat-icon>
|
||||
</button>
|
||||
</td>
|
||||
</ng-container>
|
||||
<tr mat-header-row *matHeaderRowDef="userColumns"></tr>
|
||||
<tr
|
||||
mat-row
|
||||
*matRowDef="let row; columns: userColumns"
|
||||
[class.selected-row]="selectedUser?.tid === row.tid"
|
||||
(click)="selectUser(row)">
|
||||
</tr>
|
||||
</table>
|
||||
<div class="table-footer">
|
||||
<mat-spinner *ngIf="usersLoading" diameter="24"></mat-spinner>
|
||||
<span *ngIf="!usersLoading">{{ users.length }} users</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel user-detail" *ngIf="selectedUser; else pickUser">
|
||||
<div class="panel-header">
|
||||
<h4>{{ selectedUser.username || selectedUser.tid }}</h4>
|
||||
<span class="status-pill" [class.pending]="selectedUser.status === 'P'">{{ selectedUser.status === 'A' ? 'Approved' : 'Pending' }}</span>
|
||||
</div>
|
||||
<div class="inline-error" *ngIf="userDetailError">
|
||||
<mat-icon>warning</mat-icon>
|
||||
<span>{{ userDetailError }}</span>
|
||||
</div>
|
||||
|
||||
<mat-tab-group>
|
||||
<mat-tab label="Filters">
|
||||
<div class="tab-content">
|
||||
<div class="filter-create">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Type</mat-label>
|
||||
<mat-select [(ngModel)]="filterDraft.sort" (selectionChange)="onFilterSortChange()">
|
||||
<mat-option value="C">Competence</mat-option>
|
||||
<mat-option value="P">Product</mat-option>
|
||||
<mat-option value="A">Area</mat-option>
|
||||
<mat-option value="S">Subarea</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Value</mat-label>
|
||||
<mat-select [(ngModel)]="filterDraft.sort_value">
|
||||
<mat-option *ngFor="let option of filterOptions(filterDraft.sort)" [value]="option.id">
|
||||
{{ option.id }} · {{ option.label }}
|
||||
</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Manual ID</mat-label>
|
||||
<input matInput [(ngModel)]="filterDraft.sort_value">
|
||||
</mat-form-field>
|
||||
<button mat-flat-button color="primary" (click)="addFilter()">
|
||||
<mat-icon>add</mat-icon>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="filter-list" *ngIf="selectedUserFilters.length; else noFilters">
|
||||
<div class="filter-item" *ngFor="let filter of selectedUserFilters; trackBy: trackByFilter">
|
||||
<div>
|
||||
<strong>{{ filter.sort_name }}</strong>
|
||||
<span>{{ filter.label || ('ID ' + filter.sort_value) }}</span>
|
||||
</div>
|
||||
<button mat-icon-button matTooltip="Delete filter" (click)="deleteFilter(filter)">
|
||||
<mat-icon>delete</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ng-template #noFilters>
|
||||
<p class="empty-text">This user has no saved filters.</p>
|
||||
</ng-template>
|
||||
</div>
|
||||
</mat-tab>
|
||||
|
||||
<mat-tab label="Notifications">
|
||||
<div class="tab-content settings-form" *ngIf="selectedUserSettings">
|
||||
<mat-slide-toggle [(ngModel)]="selectedUserSettings.notifications_enabled">Notifications enabled</mat-slide-toggle>
|
||||
<mat-slide-toggle [(ngModel)]="selectedUserSettings.high_gravity_only">High gravity only</mat-slide-toggle>
|
||||
|
||||
<div class="settings-grid">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Digest minutes</mat-label>
|
||||
<input matInput type="number" min="0" [(ngModel)]="selectedUserSettings.digest_minutes">
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Quiet start hour</mat-label>
|
||||
<input matInput type="number" min="0" max="23" [(ngModel)]="selectedUserSettings.quiet_start_hour">
|
||||
</mat-form-field>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Quiet end hour</mat-label>
|
||||
<input matInput type="number" min="0" max="23" [(ngModel)]="selectedUserSettings.quiet_end_hour">
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="button-row">
|
||||
<button mat-stroked-button (click)="clearQuietHours()">
|
||||
<mat-icon>do_not_disturb_on</mat-icon>
|
||||
Clear quiet hours
|
||||
</button>
|
||||
<button mat-flat-button color="primary" (click)="saveSettings()">
|
||||
<mat-icon>save</mat-icon>
|
||||
Save settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
</section>
|
||||
<ng-template #pickUser>
|
||||
<section class="panel pick-user">
|
||||
<mat-icon>touch_app</mat-icon>
|
||||
<span>Select a user to manage filters and notifications.</span>
|
||||
</section>
|
||||
</ng-template>
|
||||
</div>
|
||||
</section>
|
||||
</ng-container>
|
||||
</section>
|
||||
</mat-sidenav-content>
|
||||
</mat-sidenav-container>
|
||||
644
web/frontend/src/app/app.component.scss
Normal file
644
web/frontend/src/app/app.component.scss
Normal file
@@ -0,0 +1,644 @@
|
||||
:host {
|
||||
display: block;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.loading-screen,
|
||||
.login-shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(15, 118, 110, 0.12), rgba(245, 158, 11, 0.10)),
|
||||
#f6f8fb;
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
width: min(420px, calc(100vw - 32px));
|
||||
background: #ffffff;
|
||||
border: 1px solid #dde5ee;
|
||||
border-radius: 8px;
|
||||
padding: 32px;
|
||||
box-shadow: 0 20px 50px rgba(24, 32, 42, 0.12);
|
||||
}
|
||||
|
||||
.login-panel h1,
|
||||
.login-panel p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.login-panel h1 {
|
||||
margin-top: 14px;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.login-panel p {
|
||||
margin-top: 8px;
|
||||
color: #5b6673;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 8px;
|
||||
background: #0f766e;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
margin-top: 26px;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
background: #f6f8fb;
|
||||
}
|
||||
|
||||
.sidenav {
|
||||
width: 244px;
|
||||
border-right: 1px solid #dfe7ef;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.side-title {
|
||||
height: 72px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 20px;
|
||||
color: #0f766e;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
width: calc(100% - 20px);
|
||||
justify-content: flex-start;
|
||||
margin: 4px 10px;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.nav-button mat-icon {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.nav-button.active {
|
||||
background: #dff4ef;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
height: 72px;
|
||||
padding: 0 24px;
|
||||
background: #ffffff;
|
||||
border-bottom: 1px solid #dfe7ef;
|
||||
}
|
||||
|
||||
.topbar h2 {
|
||||
margin: 2px 0 0;
|
||||
font-size: 22px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-size: 12px;
|
||||
color: #667085;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.user-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 36px;
|
||||
margin: 0 12px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #d8e1ea;
|
||||
border-radius: 999px;
|
||||
color: #334155;
|
||||
background: #f8fafc;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.alert-banner,
|
||||
.inline-error,
|
||||
.inline-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border-radius: 8px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.alert-banner {
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 12px 12px 14px;
|
||||
border: 1px solid #fecaca;
|
||||
background: #fff5f5;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.alert-banner span {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.inline-error {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #fecaca;
|
||||
background: #fff7f7;
|
||||
color: #9f1239;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.inline-status {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #bfdbfe;
|
||||
background: #eff6ff;
|
||||
color: #1e3a8a;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.inline-error mat-icon,
|
||||
.inline-status mat-icon,
|
||||
.alert-banner mat-icon {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.view-stack {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.section-header,
|
||||
.panel-header,
|
||||
.table-footer,
|
||||
.button-row,
|
||||
.detail-title,
|
||||
.result-head,
|
||||
.result-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.section-header h3,
|
||||
.section-header p,
|
||||
.panel-header h4,
|
||||
.result-row h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.section-header h3 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.section-header p {
|
||||
margin-top: 4px;
|
||||
color: #667085;
|
||||
}
|
||||
|
||||
.kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(160px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.kpi-card {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 6px 12px;
|
||||
padding: 18px;
|
||||
border: 1px solid #dfe7ef;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.kpi-card mat-icon {
|
||||
grid-row: span 2;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.kpi-card span {
|
||||
color: #667085;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.kpi-card strong {
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.kpi-card.warn.hot mat-icon,
|
||||
.kpi-card.warn.hot strong {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.dashboard-grid,
|
||||
.users-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(360px, 0.8fr);
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.panel,
|
||||
.table-panel,
|
||||
.detail-panel {
|
||||
background: #ffffff;
|
||||
border: 1px solid #dfe7ef;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.panel,
|
||||
.detail-panel {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
justify-content: space-between;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.panel-header h4 {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.status-bad {
|
||||
color: #b42318;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.compact-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.compact-row {
|
||||
display: grid;
|
||||
grid-template-columns: 92px 1fr auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #edf1f5;
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.compact-row:hover,
|
||||
.selected-row {
|
||||
background: #edf8f6;
|
||||
}
|
||||
|
||||
.ticket-number {
|
||||
color: #0f766e;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.compact-main {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.compact-meta {
|
||||
color: #667085;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.empty-text,
|
||||
.empty-state {
|
||||
color: #667085;
|
||||
}
|
||||
|
||||
.filter-bar,
|
||||
.search-panel,
|
||||
.filter-create,
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
grid-template-columns: minmax(220px, 1.4fr) repeat(5, minmax(140px, 1fr)) auto;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #dfe7ef;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.filter-bar.compact {
|
||||
grid-template-columns: repeat(5, minmax(150px, 1fr)) auto;
|
||||
}
|
||||
|
||||
.user-filter {
|
||||
grid-template-columns: minmax(240px, 0.45fr) 180px;
|
||||
}
|
||||
|
||||
.filter-bar .mat-mdc-form-field,
|
||||
.search-panel .mat-mdc-form-field,
|
||||
.filter-create .mat-mdc-form-field,
|
||||
.settings-grid .mat-mdc-form-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-panel {
|
||||
grid-template-columns: auto minmax(280px, 1fr) auto;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #dfe7ef;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.table-panel {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
color: #667085;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.data-table td,
|
||||
.data-table th {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.data-table td:nth-child(3) {
|
||||
min-width: 280px;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.table-footer {
|
||||
justify-content: space-between;
|
||||
min-height: 56px;
|
||||
padding: 0 16px;
|
||||
border-top: 1px solid #edf1f5;
|
||||
color: #667085;
|
||||
}
|
||||
|
||||
.detail-panel {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.detail-title {
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.detail-title strong {
|
||||
font-size: 22px;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.detail-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.detail-meta span,
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 28px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: #eef2f6;
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.status-pill.pending {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.detail-panel h5 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.text-columns {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.text-columns article {
|
||||
padding: 14px;
|
||||
border: 1px solid #edf1f5;
|
||||
border-radius: 8px;
|
||||
background: #fbfcfe;
|
||||
}
|
||||
|
||||
.text-columns h6 {
|
||||
margin: 0 0 8px;
|
||||
color: #0f766e;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.text-columns p {
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.results-stack {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.result-row {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 16px;
|
||||
border: 1px solid #dfe7ef;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.result-head {
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.result-head strong {
|
||||
color: #0f766e;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.result-head span {
|
||||
margin-left: 10px;
|
||||
color: #667085;
|
||||
}
|
||||
|
||||
.result-row p {
|
||||
margin: 0;
|
||||
color: #334155;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
min-height: 120px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 8px;
|
||||
border: 1px dashed #cbd5df;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.user-table tr.mat-mdc-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.user-detail {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 16px 0 0;
|
||||
}
|
||||
|
||||
.filter-create {
|
||||
grid-template-columns: 150px minmax(180px, 1fr) 140px auto;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #edf1f5;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.filter-item div {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.filter-item span {
|
||||
color: #667085;
|
||||
}
|
||||
|
||||
.settings-form {
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.settings-grid {
|
||||
grid-template-columns: repeat(3, minmax(120px, 1fr));
|
||||
}
|
||||
|
||||
.button-row {
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.pick-user {
|
||||
min-height: 220px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 10px;
|
||||
color: #667085;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pick-user mat-icon {
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.kpi-grid {
|
||||
grid-template-columns: repeat(2, minmax(160px, 1fr));
|
||||
}
|
||||
|
||||
.dashboard-grid,
|
||||
.users-layout,
|
||||
.text-columns {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.filter-bar,
|
||||
.filter-bar.compact,
|
||||
.search-panel,
|
||||
.filter-create,
|
||||
.settings-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.sidenav {
|
||||
width: 84px;
|
||||
}
|
||||
|
||||
.side-title span,
|
||||
.nav-button span,
|
||||
.user-pill span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nav-button mat-icon {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.compact-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
670
web/frontend/src/app/app.component.ts
Normal file
670
web/frontend/src/app/app.component.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
5
web/frontend/src/assets/ticket-icon.svg
Normal file
5
web/frontend/src/assets/ticket-icon.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="12" fill="#0f766e"/>
|
||||
<path fill="#fff" d="M14 22a6 6 0 0 1 6-6h24a6 6 0 0 1 6 6v4a6 6 0 0 0 0 12v4a6 6 0 0 1-6 6H20a6 6 0 0 1-6-6v-4a6 6 0 0 0 0-12v-4Z"/>
|
||||
<path fill="#0f766e" d="M28 20h4v4h-4v-4Zm0 8h4v4h-4v-4Zm0 8h4v4h-4v-4Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 337 B |
14
web/frontend/src/index.html
Normal file
14
web/frontend/src/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Ticket Web Console</title>
|
||||
<base href="/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="icon" type="image/svg+xml" href="assets/ticket-icon.svg">
|
||||
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<ticket-root></ticket-root>
|
||||
</body>
|
||||
</html>
|
||||
14
web/frontend/src/main.ts
Normal file
14
web/frontend/src/main.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { provideAnimations } from '@angular/platform-browser/animations';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideZoneChangeDetection } from '@angular/core';
|
||||
|
||||
import { AppComponent } from './app/app.component';
|
||||
|
||||
bootstrapApplication(AppComponent, {
|
||||
providers: [
|
||||
provideZoneChangeDetection({ eventCoalescing: false, runCoalescing: false }),
|
||||
provideAnimations(),
|
||||
provideHttpClient()
|
||||
]
|
||||
}).catch((error) => console.error(error));
|
||||
29
web/frontend/src/styles.scss
Normal file
29
web/frontend/src/styles.scss
Normal file
@@ -0,0 +1,29 @@
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: #f6f8fb;
|
||||
color: #18202a;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.mat-mdc-card {
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
|
||||
.mat-mdc-button-base {
|
||||
border-radius: 6px !important;
|
||||
}
|
||||
9
web/frontend/tsconfig.app.json
Normal file
9
web/frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./out-tsc/app",
|
||||
"types": []
|
||||
},
|
||||
"files": ["src/main.ts"],
|
||||
"include": ["src/**/*.d.ts"]
|
||||
}
|
||||
29
web/frontend/tsconfig.json
Normal file
29
web/frontend/tsconfig.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./",
|
||||
"outDir": "./dist/out-tsc",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": false,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"sourceMap": true,
|
||||
"declaration": false,
|
||||
"downlevelIteration": true,
|
||||
"experimentalDecorators": true,
|
||||
"moduleResolution": "bundler",
|
||||
"importHelpers": true,
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"useDefineForClassFields": false,
|
||||
"lib": ["ES2022", "dom"]
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true,
|
||||
"strictTemplates": true
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user