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

File diff suppressed because one or more lines are too long

Binary file not shown.

68
web/frontend/angular.json Normal file
View 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

File diff suppressed because it is too large Load Diff

31
web/frontend/package.json Normal file
View 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"
}
}

View 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;
}
}

View 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>

View 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;
}
}

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;
}
}

View 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

View 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
View 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));

View 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;
}

View File

@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/app",
"types": []
},
"files": ["src/main.ts"],
"include": ["src/**/*.d.ts"]
}

View 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
}
}